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
160 changed files with 14593 additions and 20398 deletions

View File

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

View File

@ -43,7 +43,6 @@
"joi": "^17.11.0",
"leaflet": "^1.9.4",
"mjml": "^4.16.1",
"nestjs-i18n": "^10.6.5",
"nestjs-pino": "^4.4.1",
"nodemailer": "^7.0.9",
"opossum": "^8.1.3",
@ -5762,12 +5761,6 @@
"node": ">=6.5"
}
},
"node_modules/accept-language-parser": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz",
"integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -12176,34 +12169,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/nestjs-i18n": {
"version": "10.6.5",
"resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.6.5.tgz",
"integrity": "sha512-jqbZ+H7LMEfAVYqS1FM0YfZjzPDwZQq97NE4BBIfPpxzAhlfnPzaQDGpNkPE/5Ft+rawtNJOuuuaWMpDhSLwaA==",
"license": "MIT",
"dependencies": {
"accept-language-parser": "^1.5.0",
"chokidar": "^3.6.0",
"cookie": "^0.7.0",
"iterare": "^1.2.1",
"js-yaml": "^4.1.0",
"string-format": "^2.0.0"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@nestjs/common": "*",
"@nestjs/core": "*",
"class-validator": "*",
"rxjs": "*"
},
"peerDependenciesMeta": {
"class-validator": {
"optional": true
}
}
},
"node_modules/nestjs-pino": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz",
@ -14507,12 +14472,6 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-format": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz",
"integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==",
"license": "WTFPL OR MIT"
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",

View File

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

View File

@ -3,16 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core';
import {
AcceptLanguageResolver,
CookieResolver,
HeaderResolver,
I18nModule,
QueryResolver,
} from 'nestjs-i18n';
import * as path from 'path';
import * as Joi from 'joi';
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
// Import feature modules
import { AuthModule } from './application/auth/auth.module';
@ -119,29 +110,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
inject: [ConfigService],
}),
// Internationalization (FR / EN)
// Resolver chain (highest priority first):
// 1. UserPreferenceResolver — authenticated user's preferredLanguage
// 2. CookieResolver (NEXT_LOCALE) — set by frontend switcher
// 3. HeaderResolver (x-lang / x-locale)
// 4. QueryResolver (?lang=xx)
// 5. AcceptLanguageResolver
// 6. fallback → 'fr'
I18nModule.forRoot({
fallbackLanguage: 'fr',
loaderOptions: {
path: path.join(__dirname, '/i18n/'),
watch: true,
},
resolvers: [
UserPreferenceResolver,
new CookieResolver(['NEXT_LOCALE', 'lang']),
new HeaderResolver(['x-lang', 'x-locale']),
new QueryResolver(['lang', 'locale']),
AcceptLanguageResolver,
],
}),
// Database
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({

View File

@ -10,7 +10,13 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { CurrentUser } from '../decorators/current-user.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
@ -32,7 +38,7 @@ export class ApiKeysController {
@ApiOperation({
summary: 'Générer une nouvelle clé API',
description:
'Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.',
"Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.",
})
@ApiResponse({
status: 201,

View File

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

View File

@ -8,7 +8,13 @@
* - Validation for inbound API key authentication
*/
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -14,7 +22,7 @@ export class LogsController {
constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>(
'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}`);
return res.json();
} 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('limit') limit: string,
@Query('format') format: string = 'json',
@Res() res: Response
@Res() res: Response,
) {
try {
const params = new URLSearchParams();
@ -60,9 +71,10 @@ export class LogsController {
if (limit) params.set('limit', limit);
params.set('format', format);
const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, {
signal: AbortSignal.timeout(30000),
});
const upstream = await fetch(
`${this.logExporterUrl}/api/logs/export?${params}`,
{ signal: AbortSignal.timeout(30000) },
);
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({}));

View File

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

View File

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

View File

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

View File

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

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
*/
export * from './domain.exception';
export * from './invalid-port-code.exception';
export * from './invalid-rate-quote.exception';
export * from './carrier-timeout.exception';

View File

@ -1,13 +1,13 @@
/**
* PortNotFoundException
*
* Thrown when a port is not found in the database.
* Thrown when a port is not found in the database
*/
import { DomainException } from './domain.exception';
export class PortNotFoundException extends DomainException {
export class PortNotFoundException extends Error {
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

@ -14,4 +14,3 @@ export * from './booking-status.vo';
export * from './subscription-plan.vo';
export * from './subscription-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

@ -73,9 +73,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
this.buildTransporter(ip, host);
return;
} catch (err: any) {
this.logger.warn(
`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`
);
this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
}
}
@ -89,9 +87,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
private resolveViaDoH(hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`;
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, res => {
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => {
let raw = '';
res.on('data', chunk => (raw += chunk));
res.on('data', (chunk) => (raw += chunk));
res.on('end', () => {
try {
const json = JSON.parse(raw);
@ -138,7 +136,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
);
this.transporter.verify(error => {
this.transporter.verify((error) => {
if (error) {
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
} else {
@ -150,7 +148,8 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
async send(options: EmailOptions): Promise<void> {
try {
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)
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({
name: 'status',
type: 'enum',
enum: [
'PENDING_PAYMENT',
'PENDING_BANK_TRANSFER',
'PENDING',
'ACCEPTED',
'REJECTED',
'CANCELLED',
],
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING_PAYMENT',
})
@Index()
status:
| 'PENDING_PAYMENT'
| 'PENDING_BANK_TRANSFER'
| 'PENDING'
| 'ACCEPTED'
| 'REJECTED'
| 'CANCELLED';
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' })
documents: Array<{

View File

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

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')
export class PasswordResetTokenOrmEntity {

View File

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

View File

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

View File

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

View File

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

View File

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

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 { VersioningType } from '@nestjs/common';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
import { DomainExceptionFilter } from './application/filters/domain-exception.filter';
import type { Request, Response, NextFunction } from 'express';
async function bootstrap() {
@ -44,9 +42,9 @@ async function bootstrap() {
type: VersioningType.URI,
});
// Global validation pipe — i18n-aware (messages translated to caller locale)
// Global validation pipe
app.useGlobalPipes(
new I18nValidationPipe({
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: 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 ────────────────────────────────────────────────
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
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';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import Link from 'next/link';
import { motion, useInView } from 'framer-motion';
import {
Ship,
@ -14,42 +13,10 @@ import {
Linkedin,
Calendar,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type ValueKey = 'excellence' | 'transparency' | 'collaboration' | 'innovation';
type TeamKey = 'ceo' | 'cto' | 'coo' | 'vpSales' | 'vpEng' | 'vpProduct';
type TimelineKey = '2021' | '2022' | '2023' | '2024' | '2025';
type StatKey = 'clients' | 'carriers' | 'countries' | 'bookings';
const VALUES: { key: ValueKey; icon: LucideIcon; color: string }[] = [
{ key: 'excellence', icon: Target, color: 'from-blue-500 to-cyan-500' },
{ key: 'transparency', icon: Heart, color: 'from-pink-500 to-rose-500' },
{ key: 'collaboration', icon: Users, color: 'from-purple-500 to-indigo-500' },
{ key: 'innovation', icon: TrendingUp, color: 'from-orange-500 to-amber-500' },
];
const TEAM: { key: TeamKey; name: string; linkedin: string }[] = [
{ key: 'ceo', name: 'Jean-Pierre Durand', linkedin: '#' },
{ key: 'cto', name: 'Marie Lefebvre', linkedin: '#' },
{ key: 'coo', name: 'Thomas Martin', linkedin: '#' },
{ key: 'vpSales', name: 'Sophie Bernard', linkedin: '#' },
{ key: 'vpEng', name: 'Alexandre Petit', linkedin: '#' },
{ key: 'vpProduct', name: 'Claire Moreau', linkedin: '#' },
];
const TIMELINE_YEARS: TimelineKey[] = ['2021', '2022', '2023', '2024', '2025'];
const STATS: { key: StatKey; value: string }[] = [
{ key: 'clients', value: '500+' },
{ key: 'carriers', value: '50+' },
{ key: 'countries', value: '15' },
{ key: 'bookings', value: '100K+' },
];
export default function AboutPage() {
const t = useTranslations('marketing.about');
const heroRef = useRef(null);
const missionRef = useRef(null);
const valuesRef = useRef(null);
@ -64,6 +31,117 @@ export default function AboutPage() {
const isTimelineInView = useInView(timelineRef, { once: true });
const isStatsInView = useInView(statsRef, { once: true });
const values = [
{
icon: Target,
title: 'Excellence',
description:
'Nous visons l\'excellence dans chaque aspect de notre plateforme, en offrant une expérience utilisateur de premier ordre.',
color: 'from-blue-500 to-cyan-500',
},
{
icon: Heart,
title: 'Transparence',
description:
'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.',
color: 'from-pink-500 to-rose-500',
},
{
icon: Users,
title: 'Collaboration',
description:
'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.',
color: 'from-purple-500 to-indigo-500',
},
{
icon: TrendingUp,
title: 'Innovation',
description:
'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.',
color: 'from-orange-500 to-amber-500',
},
];
const team = [
{
name: 'Jean-Pierre Durand',
role: 'CEO & Co-fondateur',
bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping',
image: '/assets/images/team/ceo.jpg',
linkedin: '#',
},
{
name: 'Marie Lefebvre',
role: 'CTO & Co-fondatrice',
bio: 'Ex-Google, experte en plateformes B2B et systèmes distribués',
image: '/assets/images/team/cto.jpg',
linkedin: '#',
},
{
name: 'Thomas Martin',
role: 'COO',
bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales',
image: '/assets/images/team/coo.jpg',
linkedin: '#',
},
{
name: 'Sophie Bernard',
role: 'VP Sales',
bio: '15 ans d\'expérience commerciale dans le secteur logistique',
image: '/assets/images/team/vp-sales.jpg',
linkedin: '#',
},
{
name: 'Alexandre Petit',
role: 'VP Engineering',
bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel',
image: '/assets/images/team/vp-eng.jpg',
linkedin: '#',
},
{
name: 'Claire Moreau',
role: 'VP Product',
bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit',
image: '/assets/images/team/vp-product.jpg',
linkedin: '#',
},
];
const timeline = [
{
year: '2021',
title: 'Fondation',
description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.',
},
{
year: '2022',
title: 'Première version',
description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.',
},
{
year: '2023',
title: 'Série A',
description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.',
},
{
year: '2024',
title: 'Expansion',
description: '50+ compagnies maritimes, présence dans 15 pays européens.',
},
{
year: '2025',
title: 'Leader européen',
description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.',
},
];
const stats = [
{ value: '500+', label: 'Clients actifs' },
{ value: '50+', label: 'Compagnies maritimes' },
{ value: '15', label: 'Pays couverts' },
{ value: '100K+', label: 'Réservations/an' },
];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
@ -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"
>
<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>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
Révolutionner le fret maritime,
<br />
<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>
</h1>
<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>
</motion.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">
<Target className="w-8 h-8 text-white" />
</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">
{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>
</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">
<Eye className="w-8 h-8 text-white" />
</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">
{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>
</motion.div>
</motion.div>
@ -185,9 +269,9 @@ export default function AboutPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => (
{stats.map((stat, index) => (
<motion.div
key={stat.key}
key={index}
variants={itemVariants}
className="text-center"
>
@ -199,7 +283,7 @@ export default function AboutPage() {
>
{stat.value}
</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>
))}
</div>
@ -215,9 +299,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2>
<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">
{t('valuesSubtitle')}
Les principes qui guident chacune de nos décisions
</p>
</motion.div>
@ -227,11 +311,11 @@ export default function AboutPage() {
animate={isValuesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
>
{VALUES.map((value) => {
{values.map((value, index) => {
const IconComponent = value.icon;
return (
<motion.div
key={value.key}
key={index}
variants={itemVariants}
whileHover={{ y: -10 }}
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" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
<h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3>
<p className="text-gray-600">{value.description}</p>
</motion.div>
);
})}
@ -259,13 +343,14 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2>
<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">
{t('timelineSubtitle')}
De la startup au leader européen du fret maritime digital
</p>
</motion.div>
<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">
<motion.div
initial={{ scaleY: 0 }}
@ -277,9 +362,9 @@ export default function AboutPage() {
</div>
<div className="space-y-12">
{TIMELINE_YEARS.map((year, index) => (
{timeline.map((item, index) => (
<motion.div
key={year}
key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }}
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={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
<Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
<span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
</div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
<p className="text-gray-600">{item.description}</p>
</div>
</div>
{/* Animated center dot */}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
<motion.div
initial={{ scale: 0 }}
@ -324,9 +410,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2>
<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">
{t('teamSubtitle')}
Des experts passionnés par le maritime et la technologie
</p>
</motion.div>
@ -336,9 +422,9 @@ export default function AboutPage() {
animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{TEAM.map((member) => (
{team.map((member, index) => (
<motion.div
key={member.key}
key={index}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
@ -358,8 +444,8 @@ export default function AboutPage() {
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
<p className="text-brand-turquoise font-medium mb-3">{member.role}</p>
<p className="text-gray-600 text-sm">{member.bio}</p>
</div>
</motion.div>
))}
@ -377,24 +463,25 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cta.title')}
Rejoignez l'aventure Xpeditis
</h2>
<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>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<Link
href="/register"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
>
<span>{t('cta.createAccount')}</span>
<span>Créer un compte</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
>
{t('cta.viewCareers')}
Voir les offres d'emploi
</Link>
</div>
</motion.div>

View File

@ -1,8 +1,7 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import Link from 'next/link';
import { motion, useInView } from 'framer-motion';
import {
Ship,
@ -16,34 +15,11 @@ import {
Globe,
FileText,
Anchor,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news';
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'all', icon: BookOpen },
{ key: 'industry', icon: Ship },
{ key: 'technology', icon: TrendingUp },
{ key: 'guides', icon: FileText },
{ key: 'news', icon: Globe },
];
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<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() {
const t = useTranslations('marketing.blog');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const heroRef = useRef(null);
@ -54,14 +30,121 @@ export default function BlogPage() {
const isArticlesInView = useInView(articlesRef, { 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 title = t(`articles.${article.key}.title` as any);
const excerpt = t(`articles.${article.key}.excerpt` as any);
const searchMatch =
searchQuery === '' ||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
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"
>
<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>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
Actualités & Insights
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')}
du fret maritime
</span>
</h1>
<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>
{/* 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" />
<input
type="text"
placeholder={t('searchPlaceholder')}
placeholder="Rechercher un article..."
value={searchQuery}
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"
@ -167,13 +251,13 @@ export default function BlogPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="flex flex-wrap items-center justify-center gap-4">
{CATEGORIES.map((category) => {
{categories.map((category) => {
const IconComponent = category.icon;
const isActive = selectedCategory === category.key;
const isActive = selectedCategory === category.value;
return (
<button
key={category.key}
onClick={() => setSelectedCategory(category.key)}
key={category.value}
onClick={() => setSelectedCategory(category.value)}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
isActive
? 'bg-brand-turquoise text-white'
@ -181,7 +265,7 @@ export default function BlogPage() {
}`}
>
<IconComponent className="w-4 h-4" />
<span className="font-medium">{t(`categories.${category.key}`)}</span>
<span className="font-medium">{category.label}</span>
</button>
);
})}
@ -198,7 +282,7 @@ export default function BlogPage() {
viewport={{ once: true }}
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="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">
@ -209,36 +293,36 @@ export default function BlogPage() {
<div className="max-w-2xl">
<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">
{t('featuredBadge')}
À la une
</span>
<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>
</div>
<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>
<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-2">
<User className="w-4 h-4" />
<span>{t('featured.author')}</span>
<span>{featuredArticle.author}</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" />
<span>{t('featured.date')}</span>
<span>{featuredArticle.date}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{t('featured.readTime')}</span>
<span>{featuredArticle.readTime}</span>
</div>
</div>
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<span>{t('readArticle')}</span>
<span>Lire l'article</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
</div>
@ -258,15 +342,15 @@ export default function BlogPage() {
transition={{ duration: 0.8 }}
className="flex items-center justify-between mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span>
<h2 className="text-3xl font-bold text-brand-navy">Tous les articles</h2>
<span className="text-gray-500">{filteredArticles.length} articles</span>
</motion.div>
{filteredArticles.length === 0 ? (
<div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
<p className="text-gray-500">{t('noResults.body')}</p>
<h3 className="text-xl font-medium text-gray-600">Aucun article trouvé</h3>
<p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
</div>
) : (
<motion.div
@ -283,19 +367,17 @@ export default function BlogPage() {
<Ship className="w-16 h-16 text-brand-navy/20" />
<div className="absolute top-4 left-4">
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
{t(`categories.${article.category}`)}
{categories.find((c) => c.value === article.category)?.label}
</span>
</div>
</div>
<div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
{t(`articles.${article.key}.title` as any)}
{article.title}
</h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">
{t(`articles.${article.key}.excerpt` as any)}
</p>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{article.excerpt}</p>
<div className="flex flex-wrap gap-2 mb-4">
{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">
<User className="w-4 h-4 text-brand-turquoise" />
</div>
<span>{t(`articles.${article.key}.author` as any)}</span>
<span>{article.author}</span>
</div>
<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">
<Clock className="w-4 h-4" />
<span>{t(`articles.${article.key}.readTime` as any)}</span>
<span>{article.readTime}</span>
</span>
</div>
</div>
@ -341,7 +423,7 @@ export default function BlogPage() {
className="text-center mt-12"
>
<button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
{t('loadMore')}
Charger plus d'articles
</button>
</motion.div>
)}
@ -358,27 +440,28 @@ export default function BlogPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-white mb-6">
{t('newsletter.title')}
Restez informé
</h2>
<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>
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<input
type="email"
placeholder={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"
/>
<button
type="submit"
className="w-full sm:w-auto px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold flex items-center justify-center space-x-2"
>
<span>{t('newsletter.subscribe')}</span>
<span>S'abonner</span>
<ArrowRight className="w-5 h-5" />
</button>
</form>
<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>
</motion.div>
</div>

View File

@ -8,21 +8,21 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingConfirmPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('bookingPortal.confirm');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
const handleAccept = useCallback(async () => {
setIsAccepting(true);
setError(null);
try {
@ -33,29 +33,31 @@ export default function BookingConfirmPage() {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('errorGeneric'));
setError('Une erreur est survenue lors de l\'acceptation');
}
} finally {
setIsLoading(false);
setIsAccepting(false);
}
}, [token, t]);
}, [token]);
useEffect(() => {
if (!token) {
setError(t('tokenInvalid'));
setError('Token de confirmation invalide');
setIsLoading(false);
return;
}
// Auto-accept the booking
handleAccept();
}, [token, handleAccept, t]);
}, [token, handleAccept]);
if (isLoading) {
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="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>
<p className="text-gray-600">{t('loading')}</p>
<p className="text-gray-600">Confirmation en cours...</p>
</div>
</div>
);
@ -82,24 +84,24 @@ export default function BookingConfirmPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('errorTitle')}
Erreur de confirmation
</h1>
<p className="text-gray-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800">
<strong>{t('errorReasonsTitle')}</strong>
<strong>Raisons possibles :</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>{t('errorReason1')}</li>
<li>{t('errorReason2')}</li>
<li>{t('errorReason3')}</li>
<li>Le lien a expiré</li>
<li>La demande a déjà é acceptée ou refusée</li>
<li>Le token de confirmation est invalide</li>
</ul>
</div>
<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>
</div>
</div>
@ -131,68 +133,67 @@ export default function BookingConfirmPage() {
/>
</svg>
</div>
{/* Animated rings */}
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
{t('successTitle')}
Demande acceptée !
</h1>
<p className="text-lg text-gray-600 mb-2">
{t('successHeadline')}
Merci d'avoir accepté cette demande de transport.
</p>
<p className="text-gray-500">
{t('successBody')}
Le client a é notifié par email.
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('summaryTitle')}
Récapitulatif de la réservation
</h2>
<div className="space-y-3">
<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>
</div>
<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">
{booking.origin} {booking.destination}
</span>
</div>
<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>
</div>
<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>
</div>
<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>
</div>
<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>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.transitDays')}</span>
<span className="font-semibold text-gray-900">
{t('transitDaysValue', { count: booking.transitDays })}
</span>
<span className="text-gray-600">Temps de transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
<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="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD'
@ -212,7 +213,7 @@ export default function BookingConfirmPage() {
{booking.notes && (
<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>
</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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('nextStepsTitle')}
Prochaines étapes
</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>{t('nextStep1')}</li>
<li>{t('nextStep2')}</li>
<li>{t('nextStep3')}</li>
<li>Le client va finaliser les détails du conteneur</li>
<li>Vous recevrez un email avec les documents nécessaires</li>
<li>Le paiement sera traité selon vos conditions habituelles</li>
</ul>
</div>
{/* Documents Section */}
{booking.documents && booking.documents.length > 0 && (
<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">
{booking.documents.map((doc, index) => (
<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"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
{t('labels.download')}
Télécharger
</a>
</div>
))}
@ -265,7 +266,7 @@ export default function BookingConfirmPage() {
{/* Contact Info */}
<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">
support@xpeditis.com
</a>

View File

@ -9,14 +9,11 @@
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingRejectPage() {
const params = useParams();
const token = params.token as string;
const t = useTranslations('bookingPortal.reject');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -28,13 +25,14 @@ export default function BookingRejectPage() {
useEffect(() => {
if (!token) {
setError(t('tokenInvalid'));
setError('Token de refus invalide');
setIsLoading(false);
return;
}
// Just validate the token exists, don't auto-reject
setIsLoading(false);
}, [token, t]);
}, [token]);
const handleReject = async () => {
if (!token) return;
@ -51,7 +49,7 @@ export default function BookingRejectPage() {
if (err instanceof Error) {
setError(err.message);
} else {
setError(t('errorGeneric'));
setError('Une erreur est survenue lors du refus');
}
} finally {
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="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>
<p className="text-gray-600">{t('loading')}</p>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
@ -90,34 +88,36 @@ export default function BookingRejectPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('errorTitle')}
Erreur de refus
</h1>
<p className="text-gray-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800">
<strong>{t('errorReasonsTitle')}</strong>
<strong>Raisons possibles :</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>{t('errorReason1')}</li>
<li>{t('errorReason2')}</li>
<li>{t('errorReason3')}</li>
<li>Le lien a expiré</li>
<li>La demande a déjà é acceptée ou refusée</li>
<li>Le token est invalide</li>
</ul>
</div>
<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>
</div>
</div>
);
}
// After successful rejection
if (hasRejected && booking) {
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="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="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">
@ -138,46 +138,47 @@ export default function BookingRejectPage() {
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
{t('rejectedTitle')}
Demande refusée
</h1>
<p className="text-lg text-gray-600 mb-2">
{t('rejectedHeadline')}
Vous avez refusé cette demande de transport.
</p>
<p className="text-gray-500">
{t('rejectedBody')}
Le client a é notifié par email.
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('summaryTitle')}
Récapitulatif de la demande refusée
</h2>
<div className="space-y-3">
<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>
</div>
<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">
{booking.origin} {booking.destination}
</span>
</div>
<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>
</div>
<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>
</div>
<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">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
@ -189,7 +190,7 @@ export default function BookingRejectPage() {
{reason && (
<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">
{reason}
</p>
@ -197,20 +198,22 @@ export default function BookingRejectPage() {
)}
</div>
{/* Info Message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('infoTitle')}
Information
</h3>
<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>
</div>
{/* Contact Info */}
<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">
support@xpeditis.com
</a>
@ -240,9 +243,11 @@ export default function BookingRejectPage() {
);
}
// Initial rejection form
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="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full">
{/* Warning Icon */}
<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">
<svg
@ -260,13 +265,14 @@ export default function BookingRejectPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('formTitle')}
Refuser cette demande
</h1>
<p className="text-gray-600">
{t('formIntro')}
Vous êtes sur le point de refuser cette demande de transport.
</p>
</div>
{/* Optional Reason Field */}
<div className="mb-6">
{!showReasonField ? (
<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"
>
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
@ -283,20 +289,20 @@ export default function BookingRejectPage() {
) : (
<div>
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
{t('reasonLabel')}
Raison du refus (optionnel)
</label>
<textarea
id="reason"
rows={4}
value={reason}
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"
maxLength={500}
/>
<div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500">
{t('reasonHint')}
Cette information sera communiquée au client
</p>
<span className="text-xs text-gray-400">
{reason.length}/500
@ -306,12 +312,14 @@ export default function BookingRejectPage() {
)}
</div>
{/* Warning Message */}
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<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>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
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>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('submitting')}
Refus en cours...
</>
) : (
<>
<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" />
</svg>
{t('submit')}
Confirmer le refus
</>
)}
</button>
@ -340,12 +348,13 @@ export default function BookingRejectPage() {
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"
>
{tCommon('contactSupport')}
Contacter le support
</a>
</div>
{/* Help Text */}
<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>
</div>
</div>

View File

@ -1,8 +1,7 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import Link from 'next/link';
import { motion, useInView, AnimatePresence } from 'framer-motion';
import {
Briefcase,
@ -23,63 +22,12 @@ import {
LineChart,
Headphones,
Megaphone,
type LucideIcon,
} from 'lucide-react';
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() {
const t = useTranslations('marketing.careers');
const [selectedDepartment, setSelectedDepartment] = useState<DepartmentValue>('all');
const [selectedLocation, setSelectedLocation] = useState<LocationValue>('all');
const [selectedDepartment, setSelectedDepartment] = useState('all');
const [selectedLocation, setSelectedLocation] = useState('all');
const [expandedJob, setExpandedJob] = useState<number | null>(null);
const heroRef = useRef(null);
@ -92,7 +40,161 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { 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 locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
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"
>
<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>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
Construisons ensemble
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')}
le futur du maritime
</span>
</h1>
<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>
<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"
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" />
</a>
<Link
href="/about"
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>
</div>
</motion.div>
@ -192,9 +295,14 @@ export default function CareersPage() {
<section className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-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
key={stat.key}
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@ -202,7 +310,7 @@ export default function CareersPage() {
className="text-center"
>
<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>
))}
</div>
@ -219,10 +327,10 @@ export default function CareersPage() {
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('benefitsTitle')}
Pourquoi nous rejoindre ?
</h2>
<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>
</motion.div>
@ -232,11 +340,11 @@ export default function CareersPage() {
animate={isBenefitsInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{BENEFITS.map((benefit) => {
{benefits.map((benefit, index) => {
const IconComponent = benefit.icon;
return (
<motion.div
key={benefit.key}
key={index}
variants={itemVariants}
whileHover={{ y: -5 }}
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">
<IconComponent className="w-7 h-7 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{benefit.title}</h3>
<p className="text-gray-600">{benefit.description}</p>
</motion.div>
);
})}
@ -263,15 +371,21 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cultureTitle')}
Notre culture
</h2>
<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>
<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
key={itemKey}
key={index}
initial={{ opacity: 0, x: -20 }}
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
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">
<ChevronRight className="w-4 h-4 text-white" />
</div>
<span>{t(`culture.${itemKey}`)}</span>
<span>{item}</span>
</motion.li>
))}
</ul>
@ -315,10 +429,10 @@ export default function CareersPage() {
className="text-center mb-12"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('jobsTitle')}
Nos offres d'emploi
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('jobsSubtitle')}
Trouvez le poste qui correspond à vos ambitions
</p>
</motion.div>
@ -332,12 +446,12 @@ export default function CareersPage() {
<div className="relative">
<select
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"
>
{DEPARTMENT_VALUES.map((value) => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
{departments.map((dept) => (
<option key={dept.value} value={dept.value}>
{dept.label}
</option>
))}
</select>
@ -346,12 +460,12 @@ export default function CareersPage() {
<div className="relative">
<select
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"
>
{LOCATION_VALUES.map((value) => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
{locations.map((loc) => (
<option key={loc.value} value={loc.value}>
{loc.label}
</option>
))}
</select>
@ -369,8 +483,8 @@ export default function CareersPage() {
{filteredJobs.length === 0 ? (
<div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3>
<p className="text-gray-500">{t('noJobs.body')}</p>
<h3 className="text-xl font-medium text-gray-600">Aucune offre trouvée</h3>
<p className="text-gray-500">Essayez de modifier vos filtres</p>
</div>
) : (
filteredJobs.map((job) => {
@ -393,15 +507,15 @@ export default function CareersPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<h3 className="text-xl font-bold text-brand-navy">{t(`jobs.${job.key}.title`)}</h3>
<h3 className="text-xl font-bold text-brand-navy">{job.title}</h3>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" />
<span>{t(`departments.${job.department}` as any)}</span>
<span>{job.department}</span>
</span>
<span className="flex items-center space-x-1">
<MapPin className="w-4 h-4" />
<span>{t(`locations.${job.location}` as any)}</span>
<span>{job.location}</span>
</span>
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
@ -414,7 +528,7 @@ export default function CareersPage() {
<div className="hidden md:flex items-center space-x-2">
{job.remote && (
<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 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"
>
<div className="p-6 bg-gray-50">
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4>
<p className="text-gray-600 mb-6">{job.description}</p>
<h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4>
<ul className="space-y-2 mb-6">
{JOB_REQ_KEYS.map((reqKey) => (
<li key={reqKey} className="flex items-start space-x-2 text-gray-600">
{job.requirements.map((req, index) => (
<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" />
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
<span>{req}</span>
</li>
))}
</ul>
@ -455,11 +569,11 @@ export default function CareersPage() {
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"
>
<span>{t('jobCard.apply')}</span>
<span>Postuler</span>
<ArrowRight className="w-4 h-4" />
</Link>
<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>
</div>
</div>
@ -484,16 +598,17 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-brand-navy mb-6">
{t('cta.title')}
Pas de poste correspondant ?
</h2>
<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>
<Link
href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
>
<span>{t('cta.spontaneous')}</span>
<span>Candidature spontanée</span>
<ArrowRight className="w-5 h-5" />
</Link>
</motion.div>

View File

@ -1,16 +1,13 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
export default function CarrierAcceptPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('carrierPortal');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -21,18 +18,20 @@ export default function CarrierAcceptPage() {
useEffect(() => {
const acceptBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) {
return;
}
hasCalledApi.current = true;
if (!token) {
setError(t('common.tokenMissing'));
setError('Token manquant');
setLoading(false);
return;
}
try {
// Appeler l'API backend pour accepter le booking
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/accept/${token}`, {
method: 'GET',
@ -46,17 +45,17 @@ export default function CarrierAcceptPage() {
try {
errorData = await response.json();
} 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')) {
errorMessage = t('common.bookingAlreadyAccepted');
errorMessage = 'Ce booking a déjà été accepté.';
} 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')) {
errorMessage = t('common.bookingNotFound');
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
}
throw new Error(errorMessage);
@ -64,6 +63,7 @@ export default function CarrierAcceptPage() {
setLoading(false);
// Démarrer le compte à rebours
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
@ -78,13 +78,13 @@ export default function CarrierAcceptPage() {
return () => clearInterval(timer);
} catch (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);
}
};
acceptBooking();
}, [token, router, t]);
}, [token, router]);
if (loading) {
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">
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{t('accept.loadingTitle')}
Traitement en cours...
</h1>
<p className="text-gray-600">
{t('accept.loadingMessage')}
Nous traitons votre acceptation.
</p>
</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="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" />
<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>
<button
onClick={() => router.push('/')}
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>
</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">
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{t('accept.thanksTitle')}
Merci !
</h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
<p className="text-green-800 font-medium text-lg mb-2">
{t('accept.successHeadline')}
Votre acceptation a bien é prise en compte
</p>
<p className="text-green-700 text-sm">
{t('accept.successBody')}
Nous vous remercions d'avoir accepté cette demande de transport.
</p>
</div>
<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>
<button
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"
>
{t('common.backHome')}
Retour à l'accueil
</button>
</div>
</div>

View File

@ -2,7 +2,6 @@
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import {
FileText,
@ -56,13 +55,13 @@ interface AccessRequirements {
status: string;
}
const DOCUMENT_TYPE_KEYS = [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
] as const;
const documentTypeLabels: Record<string, string> = {
BILL_OF_LADING: 'Connaissement',
PACKING_LIST: 'Liste de colisage',
COMMERCIAL_INVOICE: 'Facture commerciale',
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
OTHER: 'Autre document',
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
@ -81,7 +80,6 @@ const getFileIcon = (mimeType: string) => {
export default function CarrierDocumentsPage() {
const params = useParams();
const token = params.token as string;
const t = useTranslations('carrierPortal.documents');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -98,17 +96,10 @@ export default function CarrierDocumentsPage() {
const hasCalledApi = useRef(false);
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
const checkRequirements = async () => {
if (!token) {
setError(t('linkInvalid'));
setError('Lien invalide');
setLoading(false);
return;
}
@ -129,13 +120,13 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} 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')) {
throw new Error(t('bookingNotFound'));
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
}
throw new Error(errorMessage);
@ -144,12 +135,16 @@ export default function CarrierDocumentsPage() {
const reqData: AccessRequirements = await response.json();
setRequirements(reqData);
// If booking is not accepted yet
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);
return;
}
// If no password required, fetch documents directly
if (!reqData.requiresPassword) {
await fetchDocumentsWithoutPassword();
} else {
@ -157,7 +152,7 @@ export default function CarrierDocumentsPage() {
}
} catch (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);
}
};
@ -177,19 +172,22 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} 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 (
errorMessage.includes('pas encore été acceptée') ||
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')) {
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')) {
// Password is now required, show the form
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
setLoading(false);
return;
@ -203,7 +201,7 @@ export default function CarrierDocumentsPage() {
setLoading(false);
} catch (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);
}
};
@ -227,17 +225,17 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} 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 (
response.status === 401 ||
errorMessage.includes('incorrect') ||
errorMessage.includes('invalid')
) {
setPasswordError(t('passwordIncorrect'));
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
setVerifying(false);
return;
}
@ -250,7 +248,7 @@ export default function CarrierDocumentsPage() {
setVerifying(false);
} catch (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);
}
};
@ -264,7 +262,7 @@ export default function CarrierDocumentsPage() {
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setPasswordError(t('passwordMissing'));
setPasswordError('Veuillez entrer le mot de passe');
return;
}
fetchDocumentsWithPassword(password.trim());
@ -274,11 +272,13 @@ export default function CarrierDocumentsPage() {
setDownloading(doc.id);
try {
// The downloadUrl is already a signed URL, open it directly
window.open(doc.downloadUrl, '_blank');
} catch (err) {
console.error('Error downloading document:', err);
alert(t('downloadError'));
alert('Erreur lors du téléchargement. Veuillez réessayer.');
} finally {
// Small delay to show loading state
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="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" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1>
<p className="text-gray-600">{t('loadingHint')}</p>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
<p className="text-gray-600">Veuillez patienter</p>
</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="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" />
<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>
<button
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"
>
{t('retry')}
Réessayer
</button>
</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">
<Lock className="w-8 h-8 text-brand-turquoise" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
<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>
{requirements.bookingNumber && (
<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>
)}
</div>
@ -349,7 +350,7 @@ export default function CarrierDocumentsPage() {
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
{t('password.passwordLabel')}
Mot de passe
</label>
<div className="relative">
<input
@ -357,7 +358,7 @@ export default function CarrierDocumentsPage() {
id="password"
value={password}
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"
autoComplete="off"
autoFocus
@ -386,12 +387,12 @@ export default function CarrierDocumentsPage() {
{verifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('password.verifying')}
Vérification...
</>
) : (
<>
<Lock className="w-5 h-5" />
{t('password.submit')}
Accéder aux documents
</>
)}
</button>
@ -399,9 +400,10 @@ export default function CarrierDocumentsPage() {
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800">
<strong>{t('password.helpTitle')}</strong>
<strong> trouver le mot de passe ?</strong>
<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>
</div>
</div>
@ -431,7 +433,7 @@ export default function CarrierDocumentsPage() {
onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
>
{t('header.refresh')}
Actualiser
</button>
</div>
</header>
@ -447,7 +449,7 @@ export default function CarrierDocumentsPage() {
</div>
{booking.bookingNumber && (
<p className="text-center text-white/70 text-sm mt-1">
{t('summary.bookingNumberPrefix')} {booking.bookingNumber}
N° {booking.bookingNumber}
</p>
)}
</div>
@ -456,33 +458,33 @@ export default function CarrierDocumentsPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<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>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<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>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p>
<p className="text-xs text-gray-500">Transit</p>
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<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>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500">
{t('summary.carrierLabel')}{' '}
Transporteur:{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span>
<span className="text-gray-500">
{t('summary.refLabel')}{' '}
Ref:{' '}
<span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
@ -496,16 +498,16 @@ export default function CarrierDocumentsPage() {
<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">
<FileText className="w-5 h-5 text-brand-turquoise" />
{t('list.heading', { count: documents.length })}
Documents ({documents.length})
</h2>
</div>
{documents.length === 0 ? (
<div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">{t('list.empty')}</p>
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
<p className="text-gray-500 text-sm mt-1">
{t('list.emptyHint')}
Les documents apparaîtront ici une fois ajoutés.
</p>
</div>
) : (
@ -521,7 +523,7 @@ export default function CarrierDocumentsPage() {
<p className="font-medium text-gray-900">{doc.fileName}</p>
<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">
{getDocumentTypeLabel(doc.type)}
{documentTypeLabels[doc.type] || doc.type}
</span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
</div>
@ -541,7 +543,7 @@ export default function CarrierDocumentsPage() {
) : (
<>
<Download className="w-4 h-4" />
<span>{t('list.download')}</span>
<span>Télécharger</span>
</>
)}
</button>
@ -553,13 +555,13 @@ export default function CarrierDocumentsPage() {
{/* Info */}
<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>
</main>
{/* Footer */}
<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>
</div>
);

View File

@ -1,16 +1,13 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { XCircle, Loader2, CheckCircle } from 'lucide-react';
export default function CarrierRejectPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('carrierPortal');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -21,18 +18,20 @@ export default function CarrierRejectPage() {
useEffect(() => {
const rejectBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) {
return;
}
hasCalledApi.current = true;
if (!token) {
setError(t('common.tokenMissing'));
setError('Token manquant');
setLoading(false);
return;
}
try {
// Appeler l'API backend pour refuser le booking
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/reject/${token}`, {
method: 'GET',
@ -46,17 +45,17 @@ export default function CarrierRejectPage() {
try {
errorData = await response.json();
} 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')) {
errorMessage = t('common.bookingAlreadyRejected');
errorMessage = 'Ce booking a déjà été refusé.';
} 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')) {
errorMessage = t('common.bookingNotFound');
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
}
throw new Error(errorMessage);
@ -64,6 +63,7 @@ export default function CarrierRejectPage() {
setLoading(false);
// Démarrer le compte à rebours
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
@ -78,13 +78,13 @@ export default function CarrierRejectPage() {
return () => clearInterval(timer);
} catch (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);
}
};
rejectBooking();
}, [token, router, t]);
}, [token, router]);
if (loading) {
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">
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{t('reject.loadingTitle')}
Traitement en cours...
</h1>
<p className="text-gray-600">
{t('reject.loadingMessage')}
Nous traitons votre refus.
</p>
</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="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" />
<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>
<button
onClick={() => router.push('/')}
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>
</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">
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{t('reject.thanksTitle')}
Merci de votre réponse
</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
<p className="text-orange-800 font-medium text-lg mb-2">
{t('reject.successHeadline')}
Votre refus a bien é pris en compte
</p>
<p className="text-orange-700 text-sm">
{t('reject.successBody')}
Nous avons bien enregistré votre décision concernant cette demande de transport.
</p>
</div>
<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>
<button
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"
>
{t('common.backHome')}
Retour à l'accueil
</button>
</div>
</div>

View File

@ -1,7 +1,6 @@
'use client';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion';
import {
Shield,
@ -16,41 +15,86 @@ import {
Edit,
Eye,
Mail,
type LucideIcon,
} from 'lucide-react';
import { Link } from '@/i18n/navigation';
import Link from 'next/link';
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() {
const t = useTranslations('marketing.compliance');
const heroRef = useRef(null);
const contentRef = useRef(null);
const isHeroInView = useInView(heroRef, { 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 = {
hidden: { opacity: 0, y: 50 },
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"
>
<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>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
Conformité
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')}
RGPD
</span>
</h1>
<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>
<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">
<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 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" />
<span className="text-white text-sm">{t('badges.dpo')}</span>
<span className="text-white text-sm">DPO désigné</span>
</div>
</div>
</motion.div>
@ -146,10 +191,10 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('rightsTitle')}
Vos droits RGPD
</h2>
<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>
</motion.div>
@ -159,11 +204,11 @@ export default function CompliancePage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
>
{RIGHTS.map((right) => {
{rights.map((right, index) => {
const IconComponent = right.icon;
return (
<motion.div
key={right.key}
key={index}
variants={itemVariants}
whileHover={{ y: -5 }}
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">
<IconComponent className="w-8 h-8 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3>
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
<h3 className="text-xl font-bold text-brand-navy mb-3">{right.title}</h3>
<p className="text-gray-600">{right.description}</p>
</motion.div>
);
})}
@ -186,20 +231,20 @@ export default function CompliancePage() {
className="mt-12 text-center"
>
<p className="text-gray-600 mb-4">
{t('rightsCta.text')}
Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<Link
href="/login"
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>
<a
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"
>
{t('rightsCta.dpo')}
Contacter le DPO
</a>
</div>
</motion.div>
@ -217,19 +262,19 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('principlesTitle')}
Nos principes de protection des données
</h2>
<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>
</motion.div>
<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;
return (
<motion.div
key={principle.key}
key={index}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
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">
<IconComponent className="w-6 h-6 text-brand-green" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
<h3 className="text-lg font-bold text-brand-navy mb-2">{principle.title}</h3>
<p className="text-gray-600 text-sm">{principle.description}</p>
</motion.div>
);
})}
@ -259,29 +304,29 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('measuresTitle')}
Mesures de protection
</h2>
<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>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{MEASURES.map((key, index) => (
{measures.map((measure, index) => (
<motion.div
key={key}
key={index}
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
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">
{MEASURE_ITEMS.map((itemKey) => (
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
{measure.items.map((item, i) => (
<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" />
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
<span>{item}</span>
</li>
))}
</ul>
@ -307,18 +352,33 @@ export default function CompliancePage() {
</div>
<div>
<h3 className="text-2xl font-bold text-brand-navy mb-4">
{t('register.title')}
Registre des traitements
</h3>
<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>
<ul className="space-y-3 text-gray-600">
{REGISTER_ITEMS.map((itemKey) => (
<li key={itemKey} className="flex items-center space-x-3">
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>{t(`register.${itemKey}` as any)}</span>
<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>
))}
</ul>
</div>
</div>
@ -338,10 +398,11 @@ export default function CompliancePage() {
>
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4">
{t('dpo.title')}
Contacter notre DPO
</h3>
<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>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<a
@ -355,7 +416,7 @@ export default function CompliancePage() {
href="/privacy"
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>
</div>
</motion.div>

View File

@ -1,7 +1,6 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion';
import {
Mail,
@ -18,26 +17,11 @@ import {
Zap,
BookOpen,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { LandingHeader, LandingFooter } from '@/components/layout';
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() {
const t = useTranslations('marketing.contact');
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
@ -80,7 +64,7 @@ export default function ContactPage() {
});
setIsSubmitted(true);
} catch (err: any) {
setError(err.message || t('form.genericError'));
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
} finally {
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 = {
hidden: { opacity: 0, y: 50 },
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"
>
<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>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
Une question ?
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')}
Nous sommes pour vous
</span>
</h1>
<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>
</motion.div>
</div>
@ -178,11 +216,11 @@ export default function ContactPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{METHODS.map((method) => {
{contactMethods.map((method, index) => {
const IconComponent = method.icon;
return (
<motion.div
key={method.key}
key={index}
variants={itemVariants}
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" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3>
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p>
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p>
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
<p className="text-gray-500 text-sm mb-2">{method.description}</p>
<p className="text-brand-turquoise font-medium">{method.value}</p>
</motion.div>
);
})}
@ -211,9 +249,9 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
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">
{t('form.description')}
Remplissez le formulaire ci-dessous et nous vous répondrons dans les plus brefs délais.
</p>
{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">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3>
<h3 className="text-2xl font-bold text-green-800 mb-2">Message envoyé !</h3>
<p className="text-green-700 mb-6">
{t('form.successBody')}
Merci pour votre message. Notre équipe vous répondra dans les 24 heures.
</p>
<button
onClick={() => {
@ -244,7 +282,7 @@ export default function ContactPage() {
}}
className="text-brand-turquoise font-medium hover:underline"
>
{t('form.sendAnother')}
Envoyer un autre message
</button>
</motion.div>
) : (
@ -252,7 +290,7 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.firstName')} *
Prénom *
</label>
<input
type="text"
@ -262,12 +300,12 @@ export default function ContactPage() {
value={formData.firstName}
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"
placeholder={t('form.firstNamePlaceholder')}
placeholder="Jean"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.lastName')} *
Nom *
</label>
<input
type="text"
@ -277,7 +315,7 @@ export default function ContactPage() {
value={formData.lastName}
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"
placeholder={t('form.lastNamePlaceholder')}
placeholder="Dupont"
/>
</div>
</div>
@ -285,7 +323,7 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.email')} *
Email *
</label>
<input
type="email"
@ -295,12 +333,12 @@ export default function ContactPage() {
value={formData.email}
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"
placeholder={t('form.emailPlaceholder')}
placeholder="jean.dupont@exemple.com"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.phone')}
Téléphone
</label>
<input
type="tel"
@ -309,14 +347,14 @@ export default function ContactPage() {
value={formData.phone}
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"
placeholder={t('form.phonePlaceholder')}
placeholder="+33 6 12 34 56 78"
/>
</div>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.company')}
Entreprise
</label>
<input
type="text"
@ -325,13 +363,13 @@ export default function ContactPage() {
value={formData.company}
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"
placeholder={t('form.companyPlaceholder')}
placeholder="Votre entreprise"
/>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.subject')} *
Sujet *
</label>
<select
id="subject"
@ -341,10 +379,9 @@ export default function ContactPage() {
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"
>
<option value="">{t('subjects.placeholder')}</option>
{SUBJECTS.map((key) => (
<option key={key} value={key}>
{t(`subjects.${key}`)}
{subjects.map((subject) => (
<option key={subject.value} value={subject.value}>
{subject.label}
</option>
))}
</select>
@ -352,7 +389,7 @@ export default function ContactPage() {
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.message')} *
Message *
</label>
<textarea
id="message"
@ -362,7 +399,7 @@ export default function ContactPage() {
value={formData.message}
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"
placeholder={t('form.messagePlaceholder')}
placeholder="Comment pouvons-nous vous aider ?"
/>
</div>
@ -380,12 +417,12 @@ export default function ContactPage() {
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>{t('form.submitting')}</span>
<span>Envoi en cours...</span>
</>
) : (
<>
<Send className="w-5 h-5" />
<span>{t('form.submit')}</span>
<span>Envoyer le message</span>
</>
)}
</button>
@ -399,72 +436,87 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
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">
{t('office.subtitle')}
Retrouvez-nous à Paris ou contactez-nous par email.
</p>
<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="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 bg-brand-turquoise">
<Building2 className="w-6 h-6 text-white" />
<div
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 className="flex-1">
<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">
{t('office.hqBadge')}
Siège social
</span>
)}
</div>
<div className="space-y-2 text-gray-600">
<div className="flex items-center space-x-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span>{t('office.address')}</span>
<span>{office.address}</span>
</div>
<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 className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-400" />
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{t('office.phone')}
<a href={`tel:${office.phone.replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{office.phone}
</a>
</div>
<div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-400" />
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise">
{t('office.email')}
<a href={`mailto:${office.email}`} className="hover:text-brand-turquoise">
{office.email}
</a>
</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Hours */}
<div className="mt-8 bg-gray-50 p-6 rounded-2xl">
<div className="flex items-center space-x-3 mb-4">
<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 className="space-y-2 text-gray-600">
<div className="flex justify-between">
<span>{t('hours.weekdays')}</span>
<span className="font-medium">{t('hours.weekdaysHours')}</span>
<span>Lundi - Vendredi</span>
<span className="font-medium">9h00 - 18h00</span>
</div>
<div className="flex justify-between">
<span>{t('hours.saturday')}</span>
<span className="font-medium">{t('hours.saturdayHours')}</span>
<span>Samedi</span>
<span className="font-medium">10h00 - 14h00</span>
</div>
<div className="flex justify-between">
<span>{t('hours.sunday')}</span>
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
<span>Dimanche</span>
<span className="font-medium text-gray-400">Fermé</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-500">
{t('hours.supportNote')}
* Support technique disponible 24/7 pour les clients Enterprise
</p>
</div>
</motion.div>
@ -472,7 +524,7 @@ export default function ContactPage() {
</div>
</section>
{/* Section 1 : After submit */}
{/* Section 1 : Ce qui se passe après l'envoi */}
<section ref={afterSubmitRef} className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
@ -481,6 +533,7 @@ export default function ContactPage() {
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"
>
{/* Decorative blobs */}
<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 -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" />
</div>
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
{t('afterSubmit.badge')}
Après votre envoi
</span>
</div>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Commitment */}
{/* Notre engagement */}
<motion.div
initial={{ opacity: 0, y: 20 }}
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">
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
</div>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3>
<h3 className="text-lg font-bold text-white">Notre engagement</h3>
</div>
<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">
{t('afterSubmit.commitmentHighlight')}
sous 48 heures ouvrées.
</span>
</p>
</motion.div>
{/* Security */}
{/* Sécurité */}
<motion.div
initial={{ opacity: 0, y: 20 }}
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">
<Shield className="w-5 h-5 text-brand-green" />
</div>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3>
<h3 className="text-lg font-bold text-white">Sécurité</h3>
</div>
<p className="text-white/80 leading-relaxed">
{t('afterSubmit.securityBody1')}
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
{t('afterSubmit.privacyLink')}
</Link>
{t('afterSubmit.securityBody2')}
Vos informations sont protégées et traitées conformément à notre{' '}
<a href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
politique de confidentialité
</a>
. Aucune donnée n'est partagée avec des tiers sans votre accord.
</p>
</motion.div>
</div>
@ -548,7 +603,7 @@ export default function ContactPage() {
</div>
</section>
{/* Section 2: Quick access */}
{/* Section 2 : Accès Rapide */}
<section ref={quickAccessRef} className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
@ -558,15 +613,15 @@ export default function ContactPage() {
>
<div className="text-center mb-10">
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
{t('quickAccess.badge')}
Accès rapide
</span>
<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>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Instant pricing */}
{/* Tarification instantanée */}
<motion.div
initial={{ opacity: 0, x: -30 }}
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">
<Zap className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3>
<h3 className="text-xl font-bold text-brand-navy mb-3">Tarification instantanée</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.pricingBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span>
{t('quickAccess.pricingBody2')}
N'attendez pas notre retour pour vos prix. Utilisez notre moteur{' '}
<span className="font-semibold text-brand-navy">Click&amp;Ship</span> pour obtenir
une cotation de fret maritime en moins de 60 secondes.
</p>
<Link
<a
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"
>
<span>{t('quickAccess.pricingCta')}</span>
<span>Accéder au Dashboard</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
</a>
</motion.div>
{/* Wiki */}
{/* Wiki Maritime */}
<motion.div
initial={{ opacity: 0, x: 30 }}
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">
<BookOpen className="w-7 h-7 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3>
<h3 className="text-xl font-bold text-brand-navy mb-3">Aide rapide</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.wikiBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span>
{t('quickAccess.wikiBody2')}
Une question sur les Incoterms ou la documentation export ? Notre{' '}
<span className="font-semibold text-brand-navy">Wiki Maritime</span> contient déjà
les réponses aux questions les plus fréquentes.
</p>
<Link
<a
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"
>
<span>{t('quickAccess.wikiCta')}</span>
<span>Consulter le Wiki</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
</a>
</motion.div>
</div>
</motion.div>

View File

@ -2,77 +2,63 @@
import { useRef } from 'react';
import { motion, useInView } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail } from 'lucide-react';
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() {
const t = useTranslations('marketing.cookies');
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 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 = {
hidden: { opacity: 0, y: 50 },
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"
>
<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>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
Politique de
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')}
Cookies
</span>
</h1>
<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 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>
</div>
{/* Wave */}
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path
@ -158,9 +148,16 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }}
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>
<p className="text-gray-600 leading-relaxed mb-4">{t('introBoxBody1')}</p>
<p className="text-gray-600 leading-relaxed">{t('introBoxBody2')}</p>
<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">
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>
</div>
</section>
@ -174,8 +171,10 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }}
className="text-center mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2>
<p className="text-gray-600">{t('typesSubtitle')}</p>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Types de cookies utilisés</h2>
<p className="text-gray-600">
Nous utilisons différents types de cookies sur notre plateforme
</p>
</motion.div>
<motion.div
@ -184,11 +183,11 @@ export default function CookiesPage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-8"
>
{COOKIE_TYPES.map((type) => {
{cookieTypes.map((type, index) => {
const IconComponent = type.icon;
return (
<motion.div
key={type.key}
key={index}
variants={itemVariants}
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" />
</div>
<div>
<h3 className="text-xl font-bold text-brand-navy">
{t(`types.${type.key}.title`)}
</h3>
<p className="text-gray-500 text-sm">
{t(`types.${type.key}.description`)}
</p>
<h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
<p className="text-gray-500 text-sm">{type.description}</p>
</div>
</div>
{type.required ? (
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
{t('required')}
Requis
</span>
) : (
<div className="flex items-center space-x-2">
<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>
@ -222,27 +217,17 @@ export default function CookiesPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.name')}
</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>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Nom</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Finalité</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Durée</th>
</tr>
</thead>
<tbody>
{type.cookies.map((cookie) => (
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
{type.cookies.map((cookie, i) => (
<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 text-gray-600">
{t(`purposes.${cookie.purposeKey}` as any)}
</td>
<td className="py-3 px-4 text-gray-500">
{t(`durations.${cookie.durationKey}` as any)}
</td>
<td className="py-3 px-4 text-gray-600">{cookie.purpose}</td>
<td className="py-3 px-4 text-gray-500">{cookie.duration}</td>
</tr>
))}
</tbody>
@ -260,15 +245,19 @@ export default function CookiesPage() {
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"
>
<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">
<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">
<li>{t('manageBullet1')}</li>
<li>{t('manageBullet2')}</li>
<li>{t('manageBullet3')}</li>
<li>Via notre bandeau de consentement accessible en bas de chaque page</li>
<li>Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)</li>
<li>En utilisant des outils tiers de gestion des cookies</li>
</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>
</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"
>
<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>
<h3 className="text-2xl font-bold text-white mb-4">Des questions sur les cookies ?</h3>
<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
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"

View File

@ -1,7 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
interface Booking {
@ -27,10 +26,6 @@ interface Booking {
}
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -48,26 +43,26 @@ export default function AdminBookingsPage() {
}, []);
const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm(t('confirmDelete'))) return;
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
setDeletingId(bookingId);
try {
await deleteAdminBooking(bookingId);
setBookings(prev => prev.filter(b => b.id !== bookingId));
} catch (err: any) {
setError(err.message || t('deleteError'));
setError(err.message || 'Erreur lors de la suppression');
} finally {
setDeletingId(null);
}
};
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);
try {
await validateBankTransfer(bookingId);
await fetchBookings();
} catch (err: any) {
setError(err.message || t('validateError'));
setError(err.message || 'Erreur lors de la validation du virement');
} finally {
setValidatingId(null);
}
@ -80,7 +75,7 @@ export default function AdminBookingsPage() {
setBookings(response.bookings || []);
setError(null);
} catch (err: any) {
setError(err.message || t('loadError'));
setError(err.message || 'Impossible de charger les réservations');
} finally {
setLoading(false);
}
@ -99,12 +94,15 @@ export default function AdminBookingsPage() {
};
const getStatusLabel = (status: string) => {
const key = status.toUpperCase();
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'];
if (allowed.includes(key)) {
return t(`status.${key}` as any);
}
return status;
const labels: Record<string, string> = {
PENDING_PAYMENT: 'Paiement en attente',
PENDING_BANK_TRANSFER: 'Virement à valider',
PENDING: 'En attente transporteur',
ACCEPTED: 'Accepté',
REJECTED: 'Rejeté',
CANCELLED: 'Annulé',
};
return labels[status.toUpperCase()] || status;
};
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="text-center">
<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>
);
@ -142,38 +140,38 @@ export default function AdminBookingsPage() {
<div className="space-y-6">
{/* Header */}
<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">
{t('subtitle')}
Toutes les réservations de la plateforme
</p>
</div>
{/* Stats Cards */}
<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="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>
<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">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.pendingCarrier')}</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
<div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.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">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.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">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search.label')}</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
<input
type="text"
placeholder={t('search.placeholder')}
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
value={searchTerm}
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"
/>
</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
value={filterStatus}
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"
>
<option value="all">{t('filter.all')}</option>
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
<option value="pending_payment">{t('status.PENDING_PAYMENT')}</option>
<option value="pending">{t('status.PENDING')}</option>
<option value="accepted">{t('status.ACCEPTED')}</option>
<option value="rejected">{t('status.REJECTED')}</option>
<option value="cancelled">{t('status.CANCELLED')}</option>
<option value="all">Tous les statuts</option>
<option value="pending_bank_transfer">Virement à valider</option>
<option value="pending_payment">Paiement en attente</option>
<option value="pending">En attente transporteur</option>
<option value="accepted">Accepté</option>
<option value="rejected">Rejeté</option>
<option value="cancelled">Annulé</option>
</select>
</div>
</div>
@ -226,25 +224,25 @@ export default function AdminBookingsPage() {
<thead className="bg-gray-50">
<tr>
<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 className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.route')}
Route
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.cargo')}
Cargo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.carrier')}
Transporteur
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')}
Statut
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.date')}
Date
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')}
Actions
</th>
</tr>
</thead>
@ -252,7 +250,7 @@ export default function AdminBookingsPage() {
{filteredBookings.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
{t('table.empty')}
Aucune réservation trouvée
</td>
</tr>
) : (
@ -278,11 +276,11 @@ export default function AdminBookingsPage() {
<div className="text-sm text-gray-900">
{booking.containerType}
{booking.palletCount != null && (
<span className="ml-1 text-gray-500">· {booking.palletCount} {t('table.pallets')}</span>
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
)}
</div>
<div className="text-xs text-gray-500 space-x-2">
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>}
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
</div>
</td>
@ -301,7 +299,7 @@ export default function AdminBookingsPage() {
{/* Date */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(dateLocale)}
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
</td>
{/* 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="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
<span className="text-sm font-medium text-gray-700">Voir les détails</span>
</button>
{(() => {
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span>
<span className="text-sm font-medium text-green-700">Valider virement</span>
</button>
) : 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
<span className="text-sm font-medium text-red-600">Supprimer</span>
</button>
</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="bg-white rounded-lg p-6 max-w-2xl w-full">
<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
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="text-gray-400 hover:text-gray-600"
@ -420,13 +418,13 @@ export default function AdminBookingsPage() {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.bookingNumber')}</label>
<label className="block text-sm font-medium text-gray-500">N° Booking</label>
<div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label>
<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)}`}>
{getStatusLabel(selectedBooking.status)}
</span>
@ -434,45 +432,45 @@ export default function AdminBookingsPage() {
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.routeSection')}</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
<label className="block text-sm font-medium text-gray-500">Origine</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.cargoSection')}</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</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>
{selectedBooking.palletCount != null && (
<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>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
<label className="block text-sm font-medium text-gray-500">Poids</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<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>
)}
@ -481,18 +479,18 @@ export default function AdminBookingsPage() {
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.priceSection')}</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">Prix</h3>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && (
<div>
<label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString(dateLocale)} </div>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} </div>
</div>
)}
{selectedBooking.priceUSD != null && (
<div>
<label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString(dateLocale)} $</div>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
</div>
)}
</div>
@ -500,18 +498,18 @@ export default function AdminBookingsPage() {
)}
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.datesSection')}</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<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">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)}
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div>
<label className="block text-gray-500">Mise à jour</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
</div>
)}
</div>
@ -528,7 +526,7 @@ export default function AdminBookingsPage() {
disabled={validatingId === selectedBooking.id}
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')}
{validatingId === selectedBooking.id ? 'Validation...' : '✓ Valider le virement'}
</button>
</div>
)}
@ -539,7 +537,7 @@ export default function AdminBookingsPage() {
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
{t('modal.close')}
Fermer
</button>
</div>
</div>

View File

@ -10,7 +10,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -28,10 +27,6 @@ import {
} from '@/components/ui/table';
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -44,7 +39,7 @@ export default function AdminCsvRatesPage() {
const data = await listCsvFiles();
setFiles(data.files || []);
} catch (err: any) {
setError(err?.message || t('loadError'));
setError(err?.message || 'Erreur lors du chargement des fichiers');
} finally {
setLoading(false);
}
@ -55,16 +50,16 @@ export default function AdminCsvRatesPage() {
}, []);
const handleDelete = async (filename: string) => {
if (!confirm(t('confirmDelete', { filename }))) {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le fichier ${filename} ?`)) {
return;
}
try {
await deleteCsvFile(filename);
alert(t('deleteSuccess', { filename }));
alert(`Fichier supprimé: ${filename}`);
fetchFiles(); // Refresh list
} 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">
{/* Page Header */}
<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">
{t('subtitle')}
Interface d'administration pour gérer les fichiers CSV de tarifs maritimes
</p>
<Badge variant="destructive" className="mt-2">
{t('adminBadge')}
ADMIN SEULEMENT
</Badge>
</div>
@ -89,9 +84,9 @@ export default function AdminCsvRatesPage() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{t('cardTitle')}</CardTitle>
<CardTitle>Configurations CSV actives</CardTitle>
<CardDescription>
{t('cardDescription')}
Liste de toutes les compagnies avec fichiers CSV configurés
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
@ -116,19 +111,19 @@ export default function AdminCsvRatesPage() {
</div>
) : files.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{t('empty')}
Aucun fichier trouvé. Uploadez un fichier CSV pour commencer.
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('table.filename')}</TableHead>
<TableHead>{t('table.size')}</TableHead>
<TableHead>{t('table.rows')}</TableHead>
<TableHead>{t('table.uploadedAt')}</TableHead>
<TableHead>{t('table.email')}</TableHead>
<TableHead>{t('table.actions')}</TableHead>
<TableHead>Fichier</TableHead>
<TableHead>Taille</TableHead>
<TableHead>Lignes</TableHead>
<TableHead>Date d'upload</TableHead>
<TableHead>Email</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -140,14 +135,14 @@ export default function AdminCsvRatesPage() {
</TableCell>
<TableCell>
{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>
)}
</TableCell>
<TableCell>
<div className="text-xs text-muted-foreground">
{new Date(file.uploadedAt).toLocaleDateString(dateLocale)}
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
</div>
</TableCell>
<TableCell>
@ -176,20 +171,23 @@ export default function AdminCsvRatesPage() {
{/* Info Card */}
<Card>
<CardHeader>
<CardTitle>{t('infoTitle')}</CardTitle>
<CardTitle>Informations</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<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>
<strong>{t('info.sizeLabel')}</strong> {t('info.sizeBody')}
<strong>Taille maximale :</strong> 10 MB par fichier
</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>
<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>
</CardContent>
</Card>

View File

@ -1,11 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document {
id: string;
@ -47,10 +45,6 @@ interface DocumentWithBooking extends Document {
}
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 [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true);
@ -72,6 +66,19 @@ export default function AdminDocumentsPage() {
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
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
@ -98,6 +105,7 @@ export default function AdminDocumentsPage() {
const allBookings = response.bookings || [];
setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = [];
const userIds = new Set<string>();
@ -105,15 +113,28 @@ export default function AdminDocumentsPage() {
userIds.add(booking.userId);
if (booking.documents && booking.documents.length > 0) {
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 actualFilePath = doc.filePath || doc.url || '';
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 = '';
if (actualMimeType.includes('/')) {
// It's a MIME type like "application/pdf"
const parts = actualMimeType.split('/');
fileType = getFileType(parts[1]);
} else {
// It's already a type or we extract from filename
fileType = getFileType(actualFileName);
}
@ -133,8 +154,10 @@ export default function AdminDocumentsPage() {
}
});
// Fetch user names using the API client
try {
const usersData = await getAllUsers();
console.log('Users data:', usersData);
if (usersData && usersData.users) {
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 => {
const userName = usersMap.get(doc.userId);
doc.userName = userName || doc.userId.substring(0, 8) + '...';
console.log(`User ${doc.userId} mapped to: ${doc.userName}`);
});
}
} catch (userError) {
console.error('Failed to fetch user names:', userError);
// If user fetch fails, keep the userId as fallback
allDocuments.forEach(doc => {
doc.userName = doc.userId.substring(0, 8) + '...';
});
@ -159,22 +187,24 @@ export default function AdminDocumentsPage() {
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || t('loadError'));
setError(err.message || 'Failed to load documents');
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]);
// Get unique users for filter (with names)
const uniqueUsers = Array.from(
new Map(
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
).values()
);
// Filter documents
const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
@ -190,11 +220,13 @@ export default function AdminDocumentsPage() {
return matchesSearch && matchesUser && matchesQuote;
});
// Pagination
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
@ -237,13 +269,13 @@ export default function AdminDocumentsPage() {
};
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm(t('confirmDelete'))) return;
if (!window.confirm('Supprimer définitivement ce document ?')) return;
setDeletingId(documentId);
try {
await deleteAdminDocument(bookingId, documentId);
setDocuments(prev => prev.filter(d => d.id !== documentId));
} catch (err: any) {
setError(err.message || t('deleteError'));
setError(err.message || 'Erreur lors de la suppression');
} finally {
setDeletingId(null);
}
@ -251,6 +283,7 @@ export default function AdminDocumentsPage() {
const handleDownload = async (url: string, fileName: string) => {
try {
// Try direct download first
const link = document.createElement('a');
link.href = url;
link.download = fileName;
@ -260,6 +293,7 @@ export default function AdminDocumentsPage() {
link.click();
document.body.removeChild(link);
// If direct download doesn't work, try fetch with blob
setTimeout(async () => {
try {
const response = await fetch(url, {
@ -286,8 +320,7 @@ export default function AdminDocumentsPage() {
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
const message = error instanceof Error ? error.message : t('unknownError');
alert(t('downloadError', { message }));
alert(`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
};
@ -296,7 +329,7 @@ export default function AdminDocumentsPage() {
<div className="flex items-center justify-center h-96">
<div className="text-center">
<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>
);
@ -304,25 +337,30 @@ export default function AdminDocumentsPage() {
return (
<div className="space-y-6">
<PageHeader
title={t('title')}
description={t('subtitle')}
/>
{/* Header */}
<div className="flex items-center justify-between">
<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 */}
<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="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>
<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">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<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>
</div>
@ -332,11 +370,11 @@ export default function AdminDocumentsPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.search')}
Recherche
</label>
<input
type="text"
placeholder={t('filters.searchPlaceholder')}
placeholder="Nom, type, route..."
value={searchTerm}
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"
@ -344,11 +382,11 @@ export default function AdminDocumentsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.quoteNumber')}
Numéro de Devis
</label>
<input
type="text"
placeholder={t('filters.quoteNumberPlaceholder')}
placeholder="Ex: #F2CAD5E1"
value={filterQuoteNumber}
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"
@ -356,14 +394,14 @@ export default function AdminDocumentsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.user')}
Utilisateur
</label>
<select
value={filterUserId}
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"
>
<option value="all">{t('filters.allUsers')}</option>
<option value="all">Tous les utilisateurs</option>
{uniqueUsers.map(user => (
<option key={user.id} value={user.id}>
{user.name}
@ -387,25 +425,25 @@ export default function AdminDocumentsPage() {
<thead className="bg-gray-50">
<tr>
<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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.type')}
Type
</th>
<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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.route')}
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')}
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.user')}
Utilisateur
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')}
Actions
</th>
</tr>
</thead>
@ -413,7 +451,7 @@ export default function AdminDocumentsPage() {
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{t('table.empty')}
Aucun document trouvé
</td>
</tr>
) : (
@ -481,27 +519,27 @@ export default function AdminDocumentsPage() {
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"
>
{t('pagination.previous')}
Précédent
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
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"
>
{t('pagination.next')}
Suivant
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> sur{' '}
<span className="font-medium">{filteredDocuments.length}</span> résultats
</p>
</div>
<div className="flex items-center gap-4">
<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
value={itemsPerPage}
onChange={(e) => {
@ -523,7 +561,7 @@ export default function AdminDocumentsPage() {
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"
>
<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">
<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>
@ -562,7 +600,7 @@ export default function AdminDocumentsPage() {
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"
>
<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">
<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>
@ -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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span>
<span className="text-sm font-medium text-gray-700">Télécharger</span>
</button>
<button
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
<span className="text-sm font-medium text-red-600">Supprimer</span>
</button>
</>
);

View File

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

View File

@ -1,10 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
import { PageHeader } from '@/components/ui/PageHeader';
interface Organization {
id: string;
@ -31,8 +29,6 @@ interface Organization {
}
export default function AdminOrganizationsPage() {
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -89,7 +85,7 @@ export default function AdminOrganizationsPage() {
setOrganizations(response.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || t('loadError'));
setError(err.message || 'Failed to load organizations');
} finally {
setLoading(false);
}
@ -98,9 +94,10 @@ export default function AdminOrganizationsPage() {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Transform formData to match API expected format
const apiData = {
name: formData.name,
type: formData.type as any,
type: formData.type as any, // OrganizationType
address_street: formData.address.street,
address_city: formData.address.city,
address_postal_code: formData.address.postalCode,
@ -114,7 +111,7 @@ export default function AdminOrganizationsPage() {
setShowCreateModal(false);
resetForm();
} 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);
resetForm();
} 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);
const result = await verifySiret(orgId);
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();
} else {
alert(result.message || t('siretInvalid'));
alert(result.message || 'SIRET invalide ou introuvable.');
}
} catch (err: any) {
alert(err.message || t('siretError'));
alert(err.message || 'Erreur lors de la verification du SIRET');
} finally {
setVerifyingId(null);
}
};
const handleApproveSiret = async (orgId: string) => {
if (!confirm(t('confirmApprove'))) return;
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
try {
setVerifyingId(orgId);
const result = await approveSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || t('siretApproveError'));
alert(err.message || 'Erreur lors de l\'approbation');
} finally {
setVerifyingId(null);
}
};
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 {
setVerifyingId(orgId);
const result = await rejectSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || t('siretRejectError'));
alert(err.message || 'Erreur lors du refus');
} finally {
setVerifyingId(null);
}
@ -216,20 +213,12 @@ export default function AdminOrganizationsPage() {
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) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<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>
);
@ -237,18 +226,21 @@ export default function AdminOrganizationsPage() {
return (
<div className="space-y-6">
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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
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>
}
/>
</div>
{/* Error Message */}
{error && (
@ -269,53 +261,53 @@ export default function AdminOrganizationsPage() {
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
{getTypeLabel(org.type)}
{org.type.replace('_', ' ')}
</span>
</div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{org.isActive ? t('active') : t('inactive')}
{org.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
{org.scac && (
<div>
<span className="font-medium">{t('scac')}:</span> {org.scac}
<span className="font-medium">SCAC:</span> {org.scac}
</div>
)}
{org.siren && (
<div>
<span className="font-medium">{t('siren')}:</span> {org.siren}
<span className="font-medium">SIREN:</span> {org.siren}
</div>
)}
<div className="flex items-center gap-2">
<span className="font-medium">{t('siret')}:</span>
<span className="font-medium">SIRET:</span>
{org.siret ? (
<>
<span>{org.siret}</span>
{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">
{t('verified')}
Verifie
</span>
) : (
<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 className="text-gray-400">{t('notProvided')}</span>
<span className="text-gray-400">Non renseigne</span>
)}
</div>
{org.contact_email && (
<div>
<span className="font-medium">{t('email')}:</span> {org.contact_email}
<span className="font-medium">Email:</span> {org.contact_email}
</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>
@ -325,7 +317,7 @@ export default function AdminOrganizationsPage() {
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"
>
{t('edit')}
Edit
</button>
{org.siret && !org.siretVerified && (
<button
@ -333,7 +325,7 @@ export default function AdminOrganizationsPage() {
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"
>
{verifyingId === org.id ? t('verifying') : t('verifyApi')}
{verifyingId === org.id ? '...' : 'Verifier API'}
</button>
)}
</div>
@ -345,7 +337,7 @@ export default function AdminOrganizationsPage() {
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"
>
{t('approveSiret')}
Approuver SIRET
</button>
) : (
<button
@ -353,7 +345,7 @@ export default function AdminOrganizationsPage() {
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"
>
{t('rejectSiret')}
Rejeter SIRET
</button>
)}
</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="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">
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
{showCreateModal ? 'Create New Organization' : 'Edit Organization'}
</h2>
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label>
<label className="block text-sm font-medium text-gray-700">Organization Name *</label>
<input
type="text"
required
@ -384,21 +376,21 @@ export default function AdminOrganizationsPage() {
</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
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
<option value="CARRIER">{t('types.CARRIER')}</option>
<option value="SHIPPER">{t('types.SHIPPER')}</option>
<option value="FREIGHT_FORWARDER">Freight Forwarder</option>
<option value="CARRIER">Carrier</option>
<option value="SHIPPER">Shipper</option>
</select>
</div>
{formData.type === 'CARRIER' && (
<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
type="text"
required={formData.type === 'CARRIER'}
@ -411,7 +403,7 @@ export default function AdminOrganizationsPage() {
)}
<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
type="text"
maxLength={9}
@ -422,19 +414,19 @@ export default function AdminOrganizationsPage() {
</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
type="text"
maxLength={14}
value={formData.siret}
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder={t('modal.siretPlaceholder')}
placeholder="12345678901234"
/>
</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
type="text"
value={formData.eori}
@ -444,7 +436,7 @@ export default function AdminOrganizationsPage() {
</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
type="tel"
value={formData.contact_phone}
@ -454,7 +446,7 @@ export default function AdminOrganizationsPage() {
</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
type="email"
value={formData.contact_email}
@ -464,7 +456,7 @@ export default function AdminOrganizationsPage() {
</div>
<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
type="text"
required
@ -478,7 +470,7 @@ export default function AdminOrganizationsPage() {
</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
type="text"
required
@ -492,7 +484,7 @@ export default function AdminOrganizationsPage() {
</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
type="text"
required
@ -506,7 +498,7 @@ export default function AdminOrganizationsPage() {
</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
type="text"
value={formData.address.state}
@ -519,7 +511,7 @@ export default function AdminOrganizationsPage() {
</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
type="text"
required
@ -534,7 +526,7 @@ export default function AdminOrganizationsPage() {
</div>
<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
type="url"
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"
>
{t('modal.cancel')}
Cancel
</button>
<button
type="submit"
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>
</div>
</form>

View File

@ -1,12 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
import { createUser } from '@/lib/api/users';
import { getAllOrganizations } from '@/lib/api/admin';
import type { UserRole } from '@/types/api';
import { PageHeader } from '@/components/ui/PageHeader';
interface User {
id: string;
@ -26,8 +24,6 @@ interface Organization {
}
export default function AdminUsersPage() {
const t = useTranslations('dashboard.admin.users');
const [users, setUsers] = useState<User[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
@ -54,6 +50,7 @@ export default function AdminUsersPage() {
password: '',
});
// Fetch users and organizations
useEffect(() => {
fetchData();
}, []);
@ -70,7 +67,7 @@ export default function AdminUsersPage() {
setOrganizations(orgsResponse.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || t('loadError'));
setError(err.message || 'Failed to load data');
} finally {
setLoading(false);
}
@ -84,7 +81,7 @@ export default function AdminUsersPage() {
setShowCreateModal(false);
resetForm();
} 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);
resetForm();
} 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);
setSelectedUser(null);
} 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);
};
const getRoleLabel = (role: string) => {
const allowed = ['USER', 'MANAGER', 'ADMIN', 'VIEWER'];
if (allowed.includes(role)) {
return t(`roles.${role}` as any);
}
return role;
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<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>
);
@ -171,18 +160,21 @@ export default function AdminUsersPage() {
return (
<div className="space-y-6">
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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
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>
}
/>
</div>
{/* Error Message */}
{error && (
@ -197,22 +189,22 @@ export default function AdminUsersPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.user')}
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.email')}
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.role')}
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.organization')}
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')}
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')}
Actions
</th>
</tr>
</thead>
@ -233,7 +225,7 @@ export default function AdminUsersPage() {
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{getRoleLabel(user.role)}
{user.role}
</span>
</td>
<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 ${
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>
</td>
<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)}
className="text-blue-600 hover:text-blue-900"
>
{t('edit')}
Edit
</button>
<button
onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900"
>
{t('delete')}
Delete
</button>
</td>
</tr>
@ -270,10 +262,10 @@ export default function AdminUsersPage() {
{showCreateModal && (
<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">
<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">
<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
type="email"
required
@ -283,7 +275,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
required
@ -293,7 +285,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
required
@ -303,27 +295,27 @@ export default function AdminUsersPage() {
/>
</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
value={formData.role}
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"
>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
<option value="ADMIN">{t('roles.ADMIN')}</option>
<option value="VIEWER">{t('roles.VIEWER')}</option>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label>
<label className="block text-sm font-medium text-gray-700">Organization</label>
<select
required
value={formData.organizationId}
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"
>
<option value="">{t('modal.selectOrganization')}</option>
<option value="">Select Organization</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>
{org.name}
@ -333,7 +325,7 @@ export default function AdminUsersPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t('modal.password')}
Password (leave empty for auto-generated)
</label>
<input
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"
>
{t('modal.cancel')}
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{t('modal.create')}
Create
</button>
</div>
</form>
@ -369,10 +361,10 @@ export default function AdminUsersPage() {
{showEditModal && selectedUser && (
<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">
<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">
<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
type="email"
disabled
@ -381,7 +373,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
required
@ -391,7 +383,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
required
@ -401,16 +393,16 @@ export default function AdminUsersPage() {
/>
</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
value={formData.role}
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"
>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
<option value="ADMIN">{t('roles.ADMIN')}</option>
<option value="VIEWER">{t('roles.VIEWER')}</option>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
<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"
>
{t('modal.cancel')}
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{t('modal.update')}
Update
</button>
</div>
</form>
@ -441,9 +433,10 @@ export default function AdminUsersPage() {
{showDeleteConfirm && selectedUser && (
<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">
<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">
{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>
<div className="flex justify-end space-x-2">
<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"
>
{t('deleteConfirm.cancel')}
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
{t('deleteConfirm.confirm')}
Delete
</button>
</div>
</div>

View File

@ -1,15 +1,17 @@
/**
* Booking Detail Page
*
* Display full booking information
*/
'use client';
import { useQuery } from '@tanstack/react-query';
import { getBooking } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
export default function BookingDetailPage() {
const t = useTranslations('dashboard.bookingDetail');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const params = useParams();
const bookingId = params.id as string;
@ -31,18 +33,10 @@ export default function BookingDetailPage() {
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getStatusLabel = (status: string) => {
const key = `status.${status}`;
try {
return t(key as any);
} catch {
return status;
}
};
const downloadPDF = async () => {
try {
alert(t('pdfNotImplemented'));
// TODO: Implement PDF download functionality
alert('PDF download functionality is not yet implemented');
console.log('Download PDF for booking:', bookingId);
} catch (error) {
console.error('Failed to download PDF:', error);
@ -60,12 +54,12 @@ export default function BookingDetailPage() {
if (!booking) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-semibold text-gray-900">{t('notFound')}</h2>
<h2 className="text-2xl font-semibold text-gray-900">Booking not found</h2>
<Link
href="/dashboard/bookings"
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
>
{t('back')}
Back to bookings
</Link>
</div>
);
@ -73,13 +67,14 @@ export default function BookingDetailPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Link
href="/dashboard/bookings"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
{t('back')}
Back to bookings
</Link>
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
@ -88,11 +83,11 @@ export default function BookingDetailPage() {
booking.status
)}`}
>
{getStatusLabel(booking.status)}
{booking.status}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
{t('createdOn', { date: new Date(booking.createdAt).toLocaleDateString(dateLocale) })}
Created on {new Date(booking.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex space-x-3">
@ -108,56 +103,59 @@ export default function BookingDetailPage() {
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{t('downloadPdf')}
Download PDF
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Cargo Details */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('cargo.title')}</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Cargo Details</h2>
<dl className="grid grid-cols-1 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">{t('cargo.description')}</dt>
<dt className="text-sm font-medium text-gray-500">Description</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.cargoDescription}</dd>
</div>
{booking.specialInstructions && (
<div>
<dt className="text-sm font-medium text-gray-500">{t('cargo.specialInstructions')}</dt>
<dt className="text-sm font-medium text-gray-500">Special Instructions</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
</div>
)}
</dl>
</div>
{/* Containers */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('containers.title', { count: booking.containers?.length || 0 })}
Containers ({booking.containers?.length || 0})
</h2>
<div className="space-y-3">
{booking.containers?.map((container, index) => (
<div key={container.id || index} className="border rounded-lg p-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.type')}</p>
<p className="text-sm font-medium text-gray-500">Type</p>
<p className="text-sm text-gray-900">{container.type}</p>
</div>
{container.containerNumber && (
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.number')}</p>
<p className="text-sm font-medium text-gray-500">Container Number</p>
<p className="text-sm text-gray-900">{container.containerNumber}</p>
</div>
)}
{container.sealNumber && (
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.seal')}</p>
<p className="text-sm font-medium text-gray-500">Seal Number</p>
<p className="text-sm text-gray-900">{container.sealNumber}</p>
</div>
)}
{container.vgm && (
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.vgm')}</p>
<p className="text-sm font-medium text-gray-500">VGM (kg)</p>
<p className="text-sm text-gray-900">{container.vgm}</p>
</div>
)}
@ -167,46 +165,47 @@ export default function BookingDetailPage() {
</div>
</div>
{/* Shipper & Consignee */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('shipper.title')}</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Shipper</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.name')}</dt>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.contact')}</dt>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.email')}</dt>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.phone')}</dt>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactPhone}</dd>
</div>
</dl>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('consignee.title')}</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Consignee</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.name')}</dt>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.contact')}</dt>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.email')}</dt>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.phone')}</dt>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
</div>
</dl>
@ -214,9 +213,11 @@ export default function BookingDetailPage() {
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Timeline */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('timeline.title')}</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Timeline</h2>
<div className="flow-root">
<ul className="-mb-8">
<li>
@ -243,9 +244,9 @@ export default function BookingDetailPage() {
</div>
<div className="min-w-0 flex-1 pt-1.5">
<div>
<p className="text-sm text-gray-900 font-medium">{t('timeline.created')}</p>
<p className="text-sm text-gray-900 font-medium">Booking Created</p>
<p className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleString(dateLocale)}
{new Date(booking.createdAt).toLocaleString()}
</p>
</div>
</div>
@ -256,17 +257,18 @@ export default function BookingDetailPage() {
</div>
</div>
{/* Quick Info */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('info.title')}</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Information</h2>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">{t('info.bookingId')}</dt>
<dt className="text-sm font-medium text-gray-500">Booking ID</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('info.lastUpdated')}</dt>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(booking.updatedAt).toLocaleString(dateLocale)}
{new Date(booking.updatedAt).toLocaleString()}
</dd>
</div>
</dl>

View File

@ -1,21 +1,22 @@
/**
* Bookings List Page
*
* Display all bookings (standard + CSV) with filters and search
*/
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link';
import { Plus, Clock } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { useTranslations, useLocale } from 'next-intl';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() {
const t = useTranslations('dashboard.bookingsList');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route');
@ -30,24 +31,29 @@ export default function BookingsListPage() {
}
}, [searchParams]);
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
const { data: csvData, isLoading, error: csvError } = useQuery({
queryKey: ['csv-bookings'],
queryFn: () =>
listCsvBookings({
page: 1,
limit: 1000,
limit: 1000, // Fetch all bookings for client-side filtering
}),
});
// Log errors for debugging
if (csvError) console.error('CSV bookings error:', csvError);
// Filter bookings based on search term, search type, and status
const filterBookings = (bookings: any[]) => {
let filtered = bookings;
// Filter by status first (if status filter is active)
if (statusFilter) {
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
}
// Then filter by search term if provided
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
@ -64,7 +70,7 @@ export default function BookingsListPage() {
case 'status':
return booking.status?.toLowerCase().includes(term);
case 'date':
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString(dateLocale);
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString('fr-FR');
return date.includes(term);
case 'quote':
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
@ -77,42 +83,52 @@ export default function BookingsListPage() {
return filtered;
};
// Get all filtered bookings
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
// Calculate pagination
const totalBookings = filteredBookings.length;
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
// Reset page to 1 when filters change
const resetPage = () => setPage(1);
const statusOptions = [
{ value: '', label: t('statusFilter.all') },
{ value: 'PENDING', label: t('status.pending') },
{ value: 'ACCEPTED', label: t('status.accepted') },
{ value: 'REJECTED', label: t('status.rejected') },
{ value: '', label: 'Tous les statuts' },
{ value: 'PENDING', label: 'En attente' },
{ value: 'ACCEPTED', label: 'Accepté' },
{ value: 'REJECTED', label: 'Refusé' },
];
const searchTypeOptions: { value: SearchType; label: string }[] = [
{ value: 'route', label: t('searchType.route') },
{ value: 'pallets', label: t('searchType.pallets') },
{ value: 'weight', label: t('searchType.weight') },
{ value: 'status', label: t('searchType.status') },
{ value: 'date', label: t('searchType.date') },
{ value: 'quote', label: t('searchType.quote') },
const searchTypeOptions = [
{ value: 'route', label: 'Route (Origine/Destination)' },
{ value: 'pallets', label: 'Palettes/Colis' },
{ value: 'weight', label: 'Poids (kg)' },
{ value: 'status', label: 'Statut' },
{ value: 'date', label: 'Date' },
{ value: 'quote', label: 'N° Devis' },
];
const getPlaceholder = () => {
const keyMap: Record<SearchType, string> = {
route: 'searchPlaceholder.route',
pallets: 'searchPlaceholder.pallets',
weight: 'searchPlaceholder.weight',
status: 'searchPlaceholder.status',
date: 'searchPlaceholder.date',
quote: 'searchPlaceholder.quote',
};
return t(keyMap[searchType] as any);
switch (searchType) {
case 'pallets':
return 'Rechercher par nombre de palettes...';
case 'weight':
return 'Rechercher par poids en kg...';
case 'route':
return 'Rechercher par ville (origine ou destination)...';
case 'status':
return 'Rechercher par statut...';
case 'date':
return 'Rechercher par date (JJ/MM/AAAA)...';
case 'quote':
return 'Rechercher par numéro de devis...';
default:
return 'Rechercher...';
}
};
const getStatusColor = (status: string) => {
@ -125,66 +141,76 @@ export default function BookingsListPage() {
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
PENDING: t('status.pending'),
ACCEPTED: t('status.accepted'),
REJECTED: t('status.rejected'),
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return map[status] || status;
return labels[status] || status;
};
return (
<div className="space-y-6">
{/* Bank transfer declared banner */}
{showTransferBanner && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
<div className="flex items-start space-x-3">
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
<p className="font-medium text-amber-800">Virement déclaré</p>
<p className="text-sm text-amber-700 mt-0.5">
{t('transferBanner.message')}
Votre virement a é enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
</p>
</div>
</div>
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button>
</div>
)}
<PageHeader
title={t('title')}
description={t('description')}
actions={
<>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredBookings}
filename={t('exportFilename')}
filename="reservations"
columns={[
{ key: 'id', label: t('export.id') },
{ key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` },
{ key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` },
{ key: 'origin', label: t('export.origin') },
{ key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
{ key: 'id', label: 'ID' },
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
{ key: 'origin', label: 'Origine' },
{ key: 'destination', label: 'Destination' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[v] || v;
}},
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</>
}
/>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label htmlFor="searchType" className="sr-only">
{t('searchType.label')}
Type de recherche
</label>
<select
id="searchType"
@ -204,7 +230,7 @@ export default function BookingsListPage() {
</div>
<div className="md:col-span-2">
<label htmlFor="search" className="sr-only">
{t('search')}
Rechercher
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@ -237,7 +263,7 @@ export default function BookingsListPage() {
</div>
<div>
<label htmlFor="status" className="sr-only">
{t('statusFilter.label')}
Statut
</label>
<select
id="status"
@ -258,91 +284,39 @@ export default function BookingsListPage() {
</div>
</div>
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
{t('loading')}
Chargement des réservations...
</div>
) : paginatedBookings && paginatedBookings.length > 0 ? (
<>
<div className="md:hidden divide-y divide-gray-200">
{paginatedBookings.map((booking: any) => (
<div key={`${booking.type}-${booking.id}`} className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold text-gray-900">
{booking.type === 'csv'
? `${booking.origin}${booking.destination}`
: booking.route || 'N/A'}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
</div>
</div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}>
{getStatusLabel(booking.status)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.pallets')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.palletsShort', { count: booking.palletCount })
: t('units.containersShort', { count: booking.containers?.length || 0 })}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.weight')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight ? t('units.kg', { value: booking.totalWeight }) : 'N/A'}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.date')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: '2-digit' })
: 'N/A'}
</div>
</div>
</div>
<div className="text-xs text-gray-400">
{booking.type === 'csv'
? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() })
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
</div>
))}
</div>
<div className="hidden md:block overflow-x-auto">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.palletsPackages')}
Palettes/Colis
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.weight')}
Poids
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.route')}
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.status')}
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.date')}
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.quoteNumber')}
N° Devis
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.bookingNumber')}
N° Booking
</th>
</tr>
</thead>
@ -352,8 +326,8 @@ export default function BookingsListPage() {
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.type === 'csv'
? t('units.palletsCount', { count: booking.palletCount })
: t('units.containersCount', { count: booking.containers?.length || 0 })}
? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
: `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
@ -362,16 +336,16 @@ export default function BookingsListPage() {
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.type === 'csv'
? t('units.kg', { value: booking.weightKG })
? `${booking.weightKG} kg`
: booking.totalWeight
? t('units.kg', { value: booking.totalWeight })
? `${booking.totalWeight} kg`
: 'N/A'}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv'
? t('units.cbm', { value: booking.volumeCBM })
? `${booking.volumeCBM} CBM`
: booking.totalVolume
? t('units.cbm', { value: booking.totalVolume })
? `${booking.totalVolume} CBM`
: ''}
</div>
</td>
@ -398,7 +372,7 @@ export default function BookingsListPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@ -419,6 +393,7 @@ export default function BookingsListPage() {
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
@ -427,25 +402,22 @@ export default function BookingsListPage() {
disabled={page === 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:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
Précédent
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page >= 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:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
>
{t('pagination.next')}
Suivant
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t.rich('pagination.showing', {
start: startIndex + 1,
end: Math.min(endIndex, totalBookings),
total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>,
})}
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">{Math.min(endIndex, totalBookings)}</span> sur{' '}
<span className="font-medium">{totalBookings}</span> résultat{totalBookings > 1 ? 's' : ''}
</p>
</div>
<div>
@ -455,14 +427,16 @@ export default function BookingsListPage() {
disabled={page === 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:bg-gray-100 disabled:text-gray-400 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">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Page numbers */}
{[...Array(totalPages)].map((_, idx) => {
const pageNum = idx + 1;
// Show first page, last page, current page, and pages around current
const showPage = pageNum === 1 ||
pageNum === totalPages ||
(pageNum >= page - 1 && pageNum <= page + 1);
@ -495,7 +469,7 @@ export default function BookingsListPage() {
disabled={page >= 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:bg-gray-100 disabled:text-gray-400 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">
<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>
@ -521,11 +495,11 @@ export default function BookingsListPage() {
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune réservation trouvée</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter
? t('empty.hasFilters')
: t('empty.noBookings')}
? 'Essayez d\'ajuster vos filtres'
: 'Commencez par créer votre première réservation'}
</p>
<div className="mt-6">
<Link
@ -533,7 +507,7 @@ export default function BookingsListPage() {
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-2 h-4 w-4" />
{t('new')}
Nouvelle Réservation
</Link>
</div>
</div>

View File

@ -1,12 +1,10 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document {
id: string;
@ -16,6 +14,7 @@ interface Document {
mimeType: string;
size: number;
uploadedAt?: Date;
// Legacy fields for compatibility
name?: string;
url?: string;
}
@ -30,8 +29,6 @@ interface DocumentWithBooking extends Document {
}
export default function UserDocumentsPage() {
const t = useTranslations('dashboard.userDocuments');
const locale = useLocale();
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true);
@ -42,27 +39,41 @@ export default function UserDocumentsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Modal state for adding documents
const [showAddModal, setShowAddModal] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
const [uploadingFiles, setUploadingFiles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Modal state for replacing documents
const [showReplaceModal, setShowReplaceModal] = useState(false);
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
const [replacingFile, setReplacingFile] = useState(false);
const replaceFileInputRef = useRef<HTMLInputElement>(null);
// Dropdown menu state
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
// Helper function to get formatted quote number
const getQuoteNumber = (booking: CsvBookingResponse): string => {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
};
// Get file extension and type
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = {
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV',
pdf: 'PDF',
doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
};
return typeMap[ext] || ext.toUpperCase();
};
@ -70,19 +81,23 @@ export default function UserDocumentsPage() {
const fetchBookingsAndDocuments = useCallback(async () => {
try {
setLoading(true);
// Fetch all user's bookings (paginated, get all pages)
const response = await listCsvBookings({ page: 1, limit: 1000 });
const allBookings = response.bookings || [];
setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = [];
allBookings.forEach((booking: CsvBookingResponse) => {
if (booking.documents && booking.documents.length > 0) {
booking.documents.forEach((doc: any, index: number) => {
// Use the correct field names from the backend
const actualFileName = doc.fileName || doc.name || 'document';
const actualFilePath = doc.filePath || doc.url || '';
const actualMimeType = doc.mimeType || doc.type || '';
// Extract clean file type from mimeType or fileName
let fileType = '';
if (actualMimeType.includes('/')) {
const parts = actualMimeType.split('/');
@ -113,16 +128,17 @@ export default function UserDocumentsPage() {
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || t('error'));
setError(err.message || 'Erreur lors du chargement des documents');
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]);
// Filter documents
const filteredDocuments = documents.filter(doc => {
const matchesSearch =
searchTerm === '' ||
@ -140,11 +156,13 @@ export default function UserDocumentsPage() {
return matchesSearch && matchesStatus && matchesQuote;
});
// Pagination
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterStatus, filterQuoteNumber]);
@ -187,12 +205,18 @@ export default function UserDocumentsPage() {
};
const getStatusLabel = (status: string) => {
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
return t(`statuses.${key}` as any) || status;
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[status] || status;
};
const handleDownload = async (url: string, fileName: string) => {
try {
// Try direct download first
const link = document.createElement('a');
link.href = url;
link.download = fileName;
@ -202,10 +226,18 @@ export default function UserDocumentsPage() {
link.click();
document.body.removeChild(link);
// If direct download doesn't work, try fetch with blob
setTimeout(async () => {
try {
const response = await fetch(url, { mode: 'cors', credentials: 'include' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const response = await fetch(url, {
mode: 'cors',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link2 = document.createElement('a');
@ -219,12 +251,15 @@ export default function UserDocumentsPage() {
console.error('Fetch download failed:', fetchError);
}
}, 100);
} catch (err) {
console.error('Error downloading file:', err);
alert(`${t('downloadError')}: ${err instanceof Error ? err.message : ''}`);
} catch (error) {
console.error('Error downloading file:', error);
alert(
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
}
};
// Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
);
@ -236,12 +271,14 @@ export default function UserDocumentsPage() {
const handleCloseModal = () => {
setShowAddModal(false);
setSelectedBookingId(null);
if (fileInputRef.current) fileInputRef.current.value = '';
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleFileUpload = async () => {
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
alert(t('addDocument.noBookingError'));
alert('Veuillez sélectionner une réservation et au moins un fichier');
return;
}
@ -256,34 +293,50 @@ export default function UserDocumentsPage() {
const token = localStorage.getItem('access_token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
}
);
if (!response.ok) throw new Error(t('addDocument.errorMessage'));
if (!response.ok) {
throw new Error('Erreur lors de l\'ajout des documents');
}
alert(t('addDocument.successMessage'));
alert('Documents ajoutés avec succès!');
handleCloseModal();
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error uploading documents:', err);
alert(`${t('addDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
fetchBookingsAndDocuments(); // Refresh the list
} catch (error) {
console.error('Error uploading documents:', error);
alert(
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
} finally {
setUploadingFiles(false);
}
};
// Toggle dropdown menu
const toggleDropdown = (docId: string) => {
setOpenDropdownId(openDropdownId === docId ? null : docId);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = () => setOpenDropdownId(null);
const handleClickOutside = () => {
setOpenDropdownId(null);
};
if (openDropdownId) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [openDropdownId]);
// Replace document handlers
const handleReplaceClick = (doc: DocumentWithBooking) => {
setOpenDropdownId(null);
setDocumentToReplace(doc);
@ -293,12 +346,14 @@ export default function UserDocumentsPage() {
const handleCloseReplaceModal = () => {
setShowReplaceModal(false);
setDocumentToReplace(null);
if (replaceFileInputRef.current) replaceFileInputRef.current.value = '';
if (replaceFileInputRef.current) {
replaceFileInputRef.current.value = '';
}
};
const handleReplaceDocument = async () => {
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
alert(t('replaceDocument.noFileError'));
alert('Veuillez sélectionner un fichier de remplacement');
return;
}
@ -310,20 +365,28 @@ export default function UserDocumentsPage() {
const token = localStorage.getItem('access_token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData }
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || t('replaceDocument.errorMessage'));
throw new Error(errorData.message || 'Erreur lors du remplacement du document');
}
alert(t('replaceDocument.successMessage'));
alert('Document remplacé avec succès!');
handleCloseReplaceModal();
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error replacing document:', err);
alert(`${t('replaceDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
fetchBookingsAndDocuments(); // Refresh the list
} catch (error) {
console.error('Error replacing document:', error);
alert(
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
} finally {
setReplacingFile(false);
}
@ -334,7 +397,7 @@ export default function UserDocumentsPage() {
<div className="flex items-center justify-center h-96">
<div className="text-center">
<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>
);
@ -342,52 +405,68 @@ export default function UserDocumentsPage() {
return (
<div className="space-y-6">
<PageHeader
title={t('title')}
description={t('description')}
actions={
<>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
<p className="mt-1 text-sm text-gray-500">
Gérez tous les documents de vos réservations
</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredDocuments}
filename="documents"
columns={[
{ key: 'fileName', label: t('export.fileName') },
{ key: 'fileType', label: t('export.type') },
{ key: 'quoteNumber', label: t('export.quoteNumber') },
{ key: 'route', label: t('export.route') },
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
{ key: 'uploadedAt', label: t('export.uploadedAt'), format: (v) => v ? new Date(v).toLocaleDateString(locale) : '' },
{ key: 'fileName', label: 'Nom du fichier' },
{ key: 'fileType', label: 'Type' },
{ key: 'quoteNumber', label: 'N° de Devis' },
{ key: 'route', label: 'Route' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[v] || v;
}},
{ key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<button
onClick={handleAddDocumentClick}
disabled={bookingsAvailableForDocuments.length === 0}
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
</button>
</>
}
<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="M12 4v16m8-8H4"
/>
</svg>
Ajouter un document
</button>
</div>
</div>
{/* Stats */}
<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="text-sm text-gray-500">{t('stats.total')}</div>
<div className="text-sm text-gray-500">Total Documents</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.withDocuments')}</div>
<div className="text-sm text-gray-500">Réservations avec Documents</div>
<div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<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>
</div>
@ -396,17 +475,17 @@ export default function UserDocumentsPage() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
<input
type="text"
placeholder={t('filters.searchPlaceholder')}
placeholder="Nom, type, route, transporteur..."
value={searchTerm}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.quoteNumber')}</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Numéro de Devis</label>
<input
type="text"
placeholder="Ex: #F2CAD5E1"
@ -416,16 +495,16 @@ export default function UserDocumentsPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<select
value={filterStatus}
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"
>
<option value="all">{t('filters.allStatuses')}</option>
<option value="PENDING">{t('statuses.PENDING')}</option>
<option value="ACCEPTED">{t('statuses.ACCEPTED')}</option>
<option value="REJECTED">{t('statuses.REJECTED')}</option>
<option value="all">Tous les statuts</option>
<option value="PENDING">En attente</option>
<option value="ACCEPTED">Accepté</option>
<option value="REJECTED">Refusé</option>
</select>
</div>
</div>
@ -443,20 +522,36 @@ export default function UserDocumentsPage() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.documentName')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.type')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.quoteNumber')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.route')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.carrier')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.actions')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nom du Document
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° de Devis
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{documents.length === 0 ? t('empty.noDocuments') : t('empty.noMatch')}
{documents.length === 0
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
: 'Aucun document ne correspond aux filtres sélectionnés.'}
</td>
</tr>
) : (
@ -467,7 +562,9 @@ export default function UserDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<span className="mr-2">
{getDocumentIcon(doc.fileType || doc.type)}
</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>
@ -481,7 +578,9 @@ export default function UserDocumentsPage() {
<div className="text-sm text-gray-900">{doc.carrierName}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
>
{getStatusLabel(doc.status)}
</span>
</td>
@ -493,15 +592,20 @@ export default function UserDocumentsPage() {
toggleDropdown(`${doc.bookingId}-${doc.id}`);
}}
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title={t('table.actions')}
title="Actions"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
{/* Dropdown Menu */}
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
<div
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
@ -515,19 +619,39 @@ export default function UserDocumentsPage() {
}}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg className="w-4 h-4 mr-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<svg
className="w-4 h-4 mr-3 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{t('actions.download')}
Télécharger
</button>
<button
onClick={() => handleReplaceClick(doc)}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg className="w-4 h-4 mr-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<svg
className="w-4 h-4 mr-3 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{t('actions.replace')}
Remplacer
</button>
</div>
</div>
@ -549,29 +673,29 @@ export default function UserDocumentsPage() {
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"
>
{t('pagination.previous')}
Précédent
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
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"
>
{t('pagination.next')}
Suivant
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t('pagination.showing', {
from: startIndex + 1,
to: Math.min(endIndex, filteredDocuments.length),
total: filteredDocuments.length,
})}
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">
{Math.min(endIndex, filteredDocuments.length)}
</span>{' '}
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
</p>
</div>
<div className="flex items-center gap-4">
<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
value={itemsPerPage}
onChange={e => {
@ -587,18 +711,26 @@ export default function UserDocumentsPage() {
<option value={100}>100</option>
</select>
</div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
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">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
@ -631,9 +763,13 @@ export default function UserDocumentsPage() {
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"
>
<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">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</nav>
@ -647,35 +783,57 @@ export default function UserDocumentsPage() {
{showAddModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseModal} />
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseModal}
/>
{/* Modal panel */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('addDocument.modalTitle')}</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Ajouter un document
</h3>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</label>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sélectionner une réservation
</label>
<select
value={selectedBookingId || ''}
onChange={e => setSelectedBookingId(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"
>
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
<option value="">-- Choisir une réservation --</option>
{bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? t('statuses.PENDING') : t('statuses.ACCEPTED')})
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fichiers à ajouter
</label>
<input
ref={fileInputRef}
type="file"
@ -683,7 +841,9 @@ export default function UserDocumentsPage() {
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="mt-1 text-xs text-gray-500">{t('addDocument.acceptedFormats')}</p>
<p className="mt-1 text-xs text-gray-500">
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
</p>
</div>
</div>
</div>
@ -698,20 +858,37 @@ export default function UserDocumentsPage() {
>
{uploadingFiles ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('addDocument.uploading')}
Envoi en cours...
</>
) : t('addDocument.add')}
) : (
'Ajouter'
)}
</button>
<button
type="button"
onClick={handleCloseModal}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
{t('addDocument.cancel')}
Annuler
</button>
</div>
</div>
@ -723,34 +900,60 @@ export default function UserDocumentsPage() {
{showReplaceModal && documentToReplace && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseReplaceModal} />
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseReplaceModal}
/>
{/* Modal panel */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('replaceDocument.modalTitle')}</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Remplacer le document
</h3>
<div className="mt-4 space-y-4">
{/* Current document info */}
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-500">{t('replaceDocument.currentDocument')}</p>
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p>
<p className="text-sm text-gray-500">Document actuel:</p>
<p className="text-sm font-medium text-gray-900 mt-1">
{documentToReplace.fileName}
</p>
<p className="text-xs text-gray-500 mt-1">
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route}
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</label>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nouveau fichier
</label>
<input
ref={replaceFileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="mt-1 text-xs text-gray-500">{t('replaceDocument.acceptedFormats')}</p>
<p className="mt-1 text-xs text-gray-500">
Formats acceptés: PDF, Word, Excel, Images
</p>
</div>
</div>
</div>
@ -765,20 +968,37 @@ export default function UserDocumentsPage() {
>
{replacingFile ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('replaceDocument.replacing')}
Remplacement en cours...
</>
) : t('replaceDocument.replace')}
) : (
'Remplacer'
)}
</button>
<button
type="button"
onClick={handleCloseReplaceModal}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
{t('replaceDocument.cancel')}
Annuler
</button>
</div>
</div>

View File

@ -1,11 +1,16 @@
/**
* Dashboard Layout
*
* Layout with sidebar navigation for dashboard pages
*/
'use client';
import { useAuth } from '@/lib/context/auth-context';
import { Link, usePathname, useRouter } from '@/i18n/navigation';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image';
import {
@ -19,8 +24,6 @@ import {
LogOut,
Lock,
Key,
Home,
User,
} from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge';
@ -31,7 +34,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const { hasFeature, subscription } = useSubscription();
const pathname = usePathname();
const router = useRouter();
const t = useTranslations('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
@ -53,15 +55,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
{ name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
{ name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
{ name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: t('nav.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
] : []),
];
@ -74,6 +77,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
@ -81,12 +85,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
/>
)}
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-6 border-b">
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
<Image
@ -113,6 +119,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{navigation.map(item => {
const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
@ -135,6 +142,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
})}
{/* Admin Panel - ADMIN role only */}
{user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t">
<AdminPanelDropdown />
@ -142,6 +150,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)}
</nav>
{/* User section */}
<div className="border-t p-4">
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
@ -165,16 +174,18 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
<LogOut className="w-4 h-4 mr-2" />
{t('logout')}
Déconnexion
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b">
{/* Top bar */}
<div className="sticky top-0 z-10 flex items-center justify-between h-16 px-6 bg-white border-b">
<button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
className="lg:hidden text-gray-500 hover:text-gray-700"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -187,48 +198,24 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</svg>
</button>
<div className="flex-1 lg:flex-none">
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
{navigation.find(item => isActive(item.href))?.name || t('topbar.defaultTitle')}
<h1 className="text-xl font-semibold text-gray-900">
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
</h1>
</div>
<div className="flex items-center space-x-3 lg:space-x-4">
<LanguageSwitcher variant="light" />
<div className="flex items-center space-x-4">
{/* Notifications */}
<NotificationDropdown />
<Link href="/dashboard/profile" className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
{/* User Initials */}
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Link>
</div>
</div>
<main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main>
{/* Page content */}
<main className="p-6">{children}</main>
</div>
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-gray-200 lg:hidden">
<div className="grid grid-cols-5 h-16">
{[
{ href: '/dashboard', icon: Home, label: t('bottomNav.home') },
{ href: '/dashboard/bookings', icon: Package, label: t('bottomNav.bookings') },
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map((item) => {
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center space-y-0.5 transition-colors ${
active ? 'text-blue-600' : 'text-gray-500 hover:text-gray-700'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}

View File

@ -1,9 +1,14 @@
/**
* Notifications Page
*
* Full page view for managing all user notifications
*/
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
import {
listNotifications,
markNotificationAsRead,
@ -15,14 +20,12 @@ import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, R
import type { ReactNode } from 'react';
export default function NotificationsPage() {
const t = useTranslations('dashboard.notificationsPage');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
const router = useRouter();
// Fetch notifications with pagination
const { data, isLoading } = useQuery({
queryKey: ['notifications', 'page', selectedFilter, currentPage],
queryFn: () =>
@ -38,6 +41,7 @@ export default function NotificationsPage() {
const totalPages = Math.ceil(total / 20);
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
// Mark single notification as read
const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: () => {
@ -45,6 +49,7 @@ export default function NotificationsPage() {
},
});
// Mark all as read
const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead,
onSuccess: () => {
@ -52,6 +57,7 @@ export default function NotificationsPage() {
},
});
// Delete notification
const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification,
onSuccess: () => {
@ -64,6 +70,7 @@ export default function NotificationsPage() {
markAsReadMutation.mutate(notification.id);
}
// Navigate to actionUrl if available
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
@ -71,7 +78,7 @@ export default function NotificationsPage() {
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm(t('deleteConfirm'))) {
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -86,16 +93,6 @@ export default function NotificationsPage() {
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
};
const getPriorityLabel = (priority: string) => {
const map: Record<string, string> = {
urgent: t('priority.urgent'),
high: t('priority.high'),
medium: t('priority.medium'),
low: t('priority.low'),
};
return map[priority] || priority.toUpperCase();
};
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = "h-8 w-8";
const icons: Record<string, ReactNode> = {
@ -123,11 +120,11 @@ export default function NotificationsPage() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t('time.now');
if (diffMins < 60) return t('time.minutes', { count: diffMins });
if (diffHours < 24) return t('time.hours', { count: diffHours });
if (diffDays < 7) return t('time.days', { count: diffDays });
return date.toLocaleDateString(dateLocale, {
if (diffMins < 1) return 'A l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
month: 'long',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -138,6 +135,7 @@ export default function NotificationsPage() {
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
@ -146,10 +144,10 @@ export default function NotificationsPage() {
<Bell className="w-8 h-8 text-blue-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
<p className="text-sm text-gray-600 mt-1">
{t('totalLabel', { count: total })}
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
{total} notification{total !== 1 ? 's' : ''} au total
{unreadCount > 0 && `${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
</p>
</div>
</div>
@ -160,18 +158,20 @@ export default function NotificationsPage() {
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<CheckCheck className="w-5 h-5" />
<span>{t('markAllRead')}</span>
<span>Tout marquer comme lu</span>
</button>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Filter Bar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex items-center space-x-3">
<Filter className="w-5 h-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
<span className="text-sm font-medium text-gray-700">Filtrer :</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map((filter) => (
<button
@ -186,7 +186,7 @@ export default function NotificationsPage() {
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{t(`filter.${filter}` as any)}
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{filter === 'unread' && unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
{unreadCount}
@ -198,21 +198,24 @@ export default function NotificationsPage() {
</div>
</div>
{/* Notifications List */}
<div className="bg-white rounded-lg shadow-sm border">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">{t('loading')}</p>
<p className="text-gray-500">Chargement des notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
<p className="text-gray-500">
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
{selectedFilter === 'unread'
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
</p>
</div>
</div>
@ -227,10 +230,12 @@ export default function NotificationsPage() {
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
{getNotificationIcon(notification.type)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-3">
@ -240,14 +245,14 @@ export default function NotificationsPage() {
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>{t('new')}</span>
<span>NOUVEAU</span>
</span>
)}
</div>
<button
onClick={(e) => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
title={t('deleteTitle')}
title="Supprimer la notification"
>
<Trash2 className="w-5 h-5 text-red-600" />
</button>
@ -257,6 +262,7 @@ export default function NotificationsPage() {
{notification.message}
</p>
{/* Metadata */}
<div className="flex items-center justify-between">
<div className="flex items-center flex-wrap gap-3 text-xs">
<span className="flex items-center space-x-1 text-gray-600">
@ -290,13 +296,13 @@ export default function NotificationsPage() {
: 'bg-blue-100 text-blue-700'
}`}
>
{getPriorityLabel(notification.priority)}
{notification.priority.toUpperCase()}
</span>
)}
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
<span>{t('viewDetails')}</span>
<span>Voir les détails</span>
<svg
className="w-4 h-4"
fill="none"
@ -321,16 +327,15 @@ export default function NotificationsPage() {
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
{t.rich('pagination.info', {
current: currentPage,
total: totalPages,
items: total,
b: (chunks) => <span className="font-semibold">{chunks}</span>,
})}
Page <span className="font-semibold">{currentPage}</span> sur{' '}
<span className="font-semibold">{totalPages}</span>
{' • '}
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
</div>
<div className="flex items-center space-x-2">
<button
@ -339,7 +344,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>{t('pagination.previous')}</span>
<span>Précédent</span>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -374,7 +379,7 @@ export default function NotificationsPage() {
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>{t('pagination.next')}</span>
<span>Suivant</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>

View File

@ -1,13 +1,18 @@
/**
* Dashboard Home Page - Clean & Colorful with Charts
* Professional design with data visualization
*/
'use client';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '@/lib/api';
import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useRouter } from 'next/navigation';
import {
Package,
PackageCheck,
@ -31,92 +36,96 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
Legend,
} from 'recharts';
export default function DashboardPage() {
const router = useRouter();
const { hasFeature, loading: subLoading } = useSubscription();
const t = useTranslations('dashboard.home');
const locale = useLocale();
// Redirect Bronze users (no dashboard feature) to bookings
useEffect(() => {
if (!subLoading && !hasFeature('dashboard')) {
router.replace('/dashboard/bookings');
}
}, [subLoading, hasFeature, router]);
// Fetch CSV booking KPIs
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getCsvBookingKPIs(),
});
// Fetch top carriers
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
queryKey: ['dashboard', 'top-carriers'],
queryFn: () => dashboardApi.getTopCarriers(),
});
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
// Prepare data for charts
const statusDistribution = csvKpis
? [
{ name: t('charts.distribution.accepted'), value: csvKpis.totalAccepted, color: '#10b981' },
{ name: t('charts.distribution.rejected'), value: csvKpis.totalRejected, color: '#ef4444' },
{ name: t('charts.distribution.pending'), value: csvKpis.totalPending, color: '#f59e0b' },
{ name: 'Acceptés', value: csvKpis.totalAccepted, color: '#10b981' },
{ name: 'Refusés', value: csvKpis.totalRejected, color: '#ef4444' },
{ name: 'En Attente', value: csvKpis.totalPending, color: '#f59e0b' },
]
: [];
const carrierWeightData = topCarriers
? topCarriers.slice(0, 5).map(c => ({
name: c.carrierName.length > 15 ? c.carrierName.substring(0, 15) + '...' : c.carrierName,
[t('charts.weightByCarrier.weight')]: Math.round(c.totalWeightKG),
poids: Math.round(c.totalWeightKG),
}))
: [];
const weightDataKey = t('charts.weightByCarrier.weight');
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-8 space-y-4 sm:space-y-6">
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
{/* Header - Compact */}
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
<div>
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">
{t('subtitle')}
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
<p className="text-gray-600 mt-1 text-sm">
Vue d'ensemble de vos réservations et performances
</p>
</div>
<div className="flex items-center space-x-2 sm:space-x-3">
<div className="flex items-center space-x-3">
<ExportButton
data={topCarriers || []}
filename={t('exportFilename')}
filename="tableau-de-bord-transporteurs"
columns={[
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'totalBookings', label: t('export.totalBookings') },
{ key: 'acceptedBookings', label: t('export.accepted') },
{ key: 'rejectedBookings', label: t('export.rejected') },
{ key: 'totalWeightKG', label: t('export.totalWeight'), format: (v) => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0' },
{ key: 'totalVolumeCBM', label: t('export.totalVolume'), format: (v) => v?.toFixed(2) || '0' },
{ key: 'acceptanceRate', label: t('export.acceptanceRate'), format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: t('export.avgPrice'), format: (v) => v?.toFixed(2) || '0' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'totalBookings', label: 'Total Réservations' },
{ key: 'acceptedBookings', label: 'Acceptées' },
{ key: 'rejectedBookings', label: 'Refusées' },
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
]}
/>
<Link href="/dashboard/search-advanced">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg font-semibold px-3 sm:px-6 py-2 sm:py-5 text-sm sm:text-base">
<Plus className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
<span className="hidden sm:inline">{t('newBooking')}</span>
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg text-base px-6 py-5 font-semibold">
<Plus className="h-5 w-5" />
Nouvelle Réservation
</Button>
</Link>
</div>
</div>
{/* KPI Cards - Compact with Color */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Bookings Acceptés */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<PackageCheck className="h-5 w-5 text-green-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.accepted')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">Acceptés</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -125,7 +134,7 @@ export default function DashboardPage() {
{csvKpis?.totalAccepted || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth || 0 })}
+{csvKpis?.acceptedThisMonth || 0} ce mois
</p>
</>
)}
@ -133,13 +142,14 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Bookings Refusés */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-red-100 flex items-center justify-center mb-2">
<PackageX className="h-5 w-5 text-red-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.rejected')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">Refusés</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -148,7 +158,7 @@ export default function DashboardPage() {
{csvKpis?.totalRejected || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth || 0 })}
+{csvKpis?.rejectedThisMonth || 0} ce mois
</p>
</>
)}
@ -156,13 +166,14 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Bookings En Attente */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-amber-100 flex items-center justify-center mb-2">
<Clock className="h-5 w-5 text-amber-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.pending')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">En Attente</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -171,7 +182,7 @@ export default function DashboardPage() {
{csvKpis?.totalPending || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
{csvKpis?.acceptanceRate.toFixed(1)}% acceptés
</p>
</>
)}
@ -179,19 +190,20 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Poids Total */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.totalWeight')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">Poids Total</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{numberFormat.format(csvKpis?.totalWeightAcceptedKG || 0)}
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
</p>
<p className="text-xs text-gray-500 mt-1">
KG {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
@ -203,14 +215,16 @@ export default function DashboardPage() {
</Card>
</div>
{/* Charts Section */}
<div className="grid gap-4 md:grid-cols-2">
{/* Distribution des Statuts - Pie Chart */}
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
{t('charts.distribution.title')}
Distribution des Réservations
</CardTitle>
<CardDescription className="text-xs text-gray-600">
{t('charts.distribution.description')}
Répartition par statut
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -242,13 +256,14 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Poids par Transporteur - Bar Chart */}
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
{t('charts.weightByCarrier.title')}
Poids par Transporteur
</CardTitle>
<CardDescription className="text-xs text-gray-600">
{t('charts.weightByCarrier.description')}
Top 5 transporteurs par poids (KG)
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -267,7 +282,7 @@ export default function DashboardPage() {
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey={weightDataKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="poids" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
@ -275,6 +290,7 @@ export default function DashboardPage() {
</Card>
</div>
{/* Performance Overview - Compact */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="border border-gray-200 shadow-sm bg-white">
<CardContent className="p-4">
@ -282,9 +298,9 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.acceptanceRate')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">Taux d'Acceptation</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
</p>
</div>
</CardContent>
@ -296,7 +312,7 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Package className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalBookings')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
@ -314,7 +330,7 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-purple-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalVolume')}</p>
<p className="text-xs font-medium text-gray-600 mb-1">Volume Total</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
@ -324,15 +340,16 @@ export default function DashboardPage() {
</Card>
</div>
{/* Top Carriers - Compact Table */}
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-3 border-b border-gray-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold text-gray-900">
{t('topCarriers.title')}
Top Transporteurs
</CardTitle>
<CardDescription className="text-xs text-gray-600 mt-1">
{t('topCarriers.description')}
Classement des meilleures compagnies
</CardDescription>
</div>
<Link href="/dashboard/bookings">
@ -341,7 +358,7 @@ export default function DashboardPage() {
size="sm"
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
>
{t('topCarriers.viewAll')}
Voir tout
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
@ -373,9 +390,9 @@ export default function DashboardPage() {
{carrier.carrierName}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}</span>
<span>{carrier.totalBookings} réservations</span>
<span></span>
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
</div>
</div>
</div>
@ -413,15 +430,15 @@ export default function DashboardPage() {
<Package className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">
{t('topCarriers.empty.title')}
Aucune réservation
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
{t('topCarriers.empty.description')}
Créez votre première réservation pour voir vos statistiques
</p>
<Link href="/dashboard/bookings">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-1.5 h-3 w-3" />
{t('topCarriers.empty.cta')}
Créer une réservation
</Button>
</Link>
</div>

View File

@ -1,3 +1,9 @@
/**
* User Profile Page
*
* Allows users to view and update their profile information
*/
'use client';
import { useState, useEffect } from 'react';
@ -6,44 +12,45 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { updateUser, changePassword } from '@/lib/api';
// Password update schema
const passwordSchema = z
.object({
currentPassword: z.string().min(1, 'Le mot de passe actuel est requis'),
newPassword: z
.string()
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
'Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial'
),
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
type PasswordFormData = z.infer<typeof passwordSchema>;
// Profile update schema
const profileSchema = z.object({
firstName: z.string().min(2, 'Le prénom doit contenir au moins 2 caractères'),
lastName: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
email: z.string().email('Adresse email invalide'),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export default function ProfilePage() {
const t = useTranslations('dashboard.profile');
const { user, refreshUser, loading } = useAuth();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const passwordSchema = z
.object({
currentPassword: z.string().min(1, t('passwordForm.errors.currentRequired')),
newPassword: z
.string()
.min(12, t('passwordForm.errors.newMin'))
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
t('passwordForm.errors.newComplexity')
),
confirmPassword: z.string().min(1, t('passwordForm.errors.confirmRequired')),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: t('passwordForm.errors.mismatch'),
path: ['confirmPassword'],
});
type PasswordFormData = z.infer<typeof passwordSchema>;
const profileSchema = z.object({
firstName: z.string().min(2, t('passwordForm.fieldErrors.firstNameMin')),
lastName: z.string().min(2, t('passwordForm.fieldErrors.lastNameMin')),
email: z.string().email(t('passwordForm.fieldErrors.emailInvalid')),
});
type ProfileFormData = z.infer<typeof profileSchema>;
// Profile form
const profileForm = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
@ -53,6 +60,7 @@ export default function ProfilePage() {
},
});
// Password form
const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
@ -62,6 +70,7 @@ export default function ProfilePage() {
},
});
// Update form values when user data loads
useEffect(() => {
if (user) {
profileForm.reset({
@ -73,6 +82,7 @@ export default function ProfilePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
// Reset password form when switching to password tab
useEffect(() => {
if (activeTab === 'password') {
passwordForm.reset({
@ -84,24 +94,26 @@ export default function ProfilePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
// Update profile mutation
const updateProfileMutation = useMutation({
mutationFn: (data: ProfileFormData) => {
if (!user?.id) throw new Error('User ID not found');
return updateUser(user.id, data);
},
onSuccess: () => {
setSuccessMessage(t('profileForm.successUpdate'));
setSuccessMessage('Profil mis à jour avec succès !');
setErrorMessage('');
refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || t('profileForm.errorUpdate'));
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setSuccessMessage('');
},
});
// Update password mutation
const updatePasswordMutation = useMutation({
mutationFn: async (data: PasswordFormData) => {
return changePassword({
@ -110,7 +122,7 @@ export default function ProfilePage() {
});
},
onSuccess: () => {
setSuccessMessage(t('passwordForm.successUpdate'));
setSuccessMessage('Mot de passe mis à jour avec succès !');
setErrorMessage('');
passwordForm.reset({
currentPassword: '',
@ -120,7 +132,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || t('passwordForm.errorUpdate'));
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
setSuccessMessage('');
},
});
@ -133,27 +145,29 @@ export default function ProfilePage() {
updatePasswordMutation.mutate(data);
};
// Show loading state while user data is being fetched
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t('loading')}</p>
<p className="text-gray-600">Chargement du profil...</p>
</div>
</div>
);
}
// Show error if user is not found after loading
if (!loading && !user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-red-600 mb-4">{t('loadError')}</p>
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{t('retry')}
Réessayer
</button>
</div>
</div>
@ -162,11 +176,13 @@ export default function ProfilePage() {
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
<h1 className="text-3xl font-bold mb-2">{t('header.title')}</h1>
<p className="text-blue-100">{t('header.subtitle')}</p>
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
</div>
{/* Success/Error Messages */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
@ -197,6 +213,7 @@ export default function ProfilePage() {
</div>
)}
{/* User Info Card */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white text-2xl font-bold">
@ -213,13 +230,14 @@ export default function ProfilePage() {
{user?.role}
</span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
{t('active')}
Actif
</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
@ -231,7 +249,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('tabs.profile')}
Informations personnelles
</button>
<button
onClick={() => setActiveTab('password')}
@ -241,7 +259,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('tabs.password')}
Modifier le mot de passe
</button>
</nav>
</div>
@ -250,9 +268,13 @@ export default function ProfilePage() {
{activeTab === 'profile' ? (
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* First Name */}
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.firstName')}
<label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prénom
</label>
<input
{...profileForm.register('firstName')}
@ -267,9 +289,13 @@ export default function ProfilePage() {
)}
</div>
{/* Last Name */}
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.lastName')}
<label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nom
</label>
<input
{...profileForm.register('lastName')}
@ -285,9 +311,10 @@ export default function ProfilePage() {
</div>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.email')}
Adresse email
</label>
<input
{...profileForm.register('email')}
@ -296,24 +323,29 @@ export default function ProfilePage() {
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500">{t('profileForm.emailHelp')}</p>
<p className="mt-1 text-xs text-gray-500">L&apos;adresse email ne peut pas être modifiée</p>
</div>
{/* Submit Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending ? t('profileForm.saving') : t('profileForm.save')}
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
) : (
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.current')}
<label
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Mot de passe actuel
</label>
<input
{...passwordForm.register('currentPassword')}
@ -329,9 +361,13 @@ export default function ProfilePage() {
)}
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.new')}
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nouveau mot de passe
</label>
<input
{...passwordForm.register('newPassword')}
@ -345,12 +381,18 @@ export default function ProfilePage() {
{passwordForm.formState.errors.newPassword.message}
</p>
)}
<p className="mt-1 text-xs text-gray-500">{t('passwordForm.newHint')}</p>
<p className="mt-1 text-xs text-gray-500">
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
</p>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.confirm')}
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirmer le nouveau mot de passe
</label>
<input
{...passwordForm.register('confirmPassword')}
@ -366,13 +408,14 @@ export default function ProfilePage() {
)}
</div>
{/* Submit Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updatePasswordMutation.isPending ? t('passwordForm.submitting') : t('passwordForm.submit')}
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
</button>
</div>
</form>

View File

@ -1,10 +1,16 @@
/**
* Advanced Rate Search Page
*
* Complete search form with all filters and best options display
* Uses only ports available in CSV rates for origin/destination selection
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useRouter } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import {
getAvailableOrigins,
getAvailableDestinations,
@ -12,14 +18,10 @@ import {
} from '@/lib/api/rates';
import dynamic from 'next/dynamic';
const PortRouteMapLoader = () => {
const t = useTranslations('dashboard.rateSearch');
return <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">{t('mapLoading')}</div>;
};
// Import dynamique pour éviter les erreurs SSR avec Leaflet
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
ssr: false,
loading: PortRouteMapLoader,
loading: () => <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">Chargement de la carte...</div>,
});
interface Package {
@ -33,17 +35,28 @@ interface Package {
}
interface SearchForm {
// General
origin: string;
destination: string;
// Conditionnement
packages: Package[];
// Douane
eurDocument: boolean;
customsStop: boolean;
exportAssistance: boolean;
// Marchandise
dangerousGoods: boolean;
specialHandling: boolean;
// Manutention
tailgate: boolean;
straps: boolean;
thermalCover: boolean;
// Autres
regulatedProducts: boolean;
appointment: boolean;
insurance: boolean;
@ -51,7 +64,6 @@ interface SearchForm {
}
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const router = useRouter();
const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '',
@ -89,17 +101,20 @@ export default function AdvancedSearchPage() {
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
// Fetch available origins from CSV rates
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'],
queryFn: getAvailableOrigins,
});
// Fetch available destinations based on selected origin
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin],
queryFn: () => getAvailableDestinations(searchForm.origin),
enabled: !!searchForm.origin,
});
// Filter origins based on search input
const filteredOrigins = (originsData?.origins || []).filter(port => {
if (!originSearch || originSearch.length < 1) return true;
const searchLower = originSearch.toLowerCase();
@ -111,6 +126,7 @@ export default function AdvancedSearchPage() {
);
});
// Filter destinations based on search input
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
if (!destinationSearch || destinationSearch.length < 1) return true;
const searchLower = destinationSearch.toLowerCase();
@ -122,8 +138,10 @@ export default function AdvancedSearchPage() {
);
});
// Reset destination when origin changes
useEffect(() => {
if (searchForm.origin && selectedDestinationPort) {
// Check if current destination is still valid for new origin
const isValidDestination = destinationsData?.destinations?.some(
d => d.code === searchForm.destination
);
@ -135,6 +153,7 @@ export default function AdvancedSearchPage() {
}
}, [searchForm.origin, destinationsData]);
// Calculate total volume and weight
const calculateTotals = () => {
let totalVolumeCBM = 0;
let totalWeightKG = 0;
@ -155,6 +174,7 @@ export default function AdvancedSearchPage() {
const handleSearch = () => {
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
// Build query parameters
const params = new URLSearchParams({
origin: searchForm.origin,
destination: searchForm.destination,
@ -170,6 +190,7 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(),
});
// Redirect to results page
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
};
@ -206,12 +227,13 @@ export default function AdvancedSearchPage() {
const renderStep1 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">{t('step1.title')}</h2>
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
<div className="grid grid-cols-2 gap-4">
{/* Origin Port with Autocomplete - Limited to CSV routes */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('step1.originLabel')} {searchForm.origin && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs"> Sélectionné</span>}
</label>
<div className="relative">
<input
@ -220,6 +242,7 @@ export default function AdvancedSearchPage() {
onChange={e => {
setOriginSearch(e.target.value);
setShowOriginDropdown(true);
// Clear selection if user modifies the input
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
@ -229,7 +252,7 @@ export default function AdvancedSearchPage() {
}}
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder={t('step1.originPlaceholder')}
placeholder="Rechercher un port d'origine..."
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
}`}
@ -264,21 +287,22 @@ export default function AdvancedSearchPage() {
))}
{filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
{t('step1.moreResults', { count: filteredOrigins.length - 15 })}
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
</div>
)}
</div>
)}
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">{t('step1.noOrigin', { query: originSearch })}</p>
<p className="text-sm text-gray-500">Aucun port d'origine trouvé pour "{originSearch}"</p>
</div>
)}
</div>
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('step1.destinationLabel')} {searchForm.destination && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs"> Sélectionné</span>}
</label>
<div className="relative">
<input
@ -287,6 +311,7 @@ export default function AdvancedSearchPage() {
onChange={e => {
setDestinationSearch(e.target.value);
setShowDestinationDropdown(true);
// Clear selection if user modifies the input
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null);
@ -295,7 +320,7 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin}
placeholder={searchForm.origin ? t('step1.destinationPlaceholder') : t('step1.destinationDisabled')}
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
@ -308,7 +333,7 @@ export default function AdvancedSearchPage() {
</div>
{searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1">
{t('step1.availableDestinations', { count: destinationsData.total, port: selectedOriginPort?.name || searchForm.origin })}
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
</p>
)}
{showDestinationDropdown && filteredDestinations.length > 0 && (
@ -333,27 +358,28 @@ export default function AdvancedSearchPage() {
))}
{filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
{t('step1.moreResults', { count: filteredDestinations.length - 15 })}
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
</div>
)}
</div>
)}
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">{t('step1.noDestination', { query: destinationSearch })}</p>
<p className="text-sm text-gray-500">Aucune destination trouvée pour "{destinationSearch}"</p>
</div>
)}
</div>
</div>
{/* Carte interactive de la route maritime */}
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">
{t('step1.routeTitle', { origin: selectedOriginPort.name, destination: selectedDestinationPort.name })}
Route maritime : {selectedOriginPort.name} {selectedDestinationPort.name}
</h3>
<p className="text-xs text-gray-500 mt-1">
{t('step1.routeDescription')}
Distance approximative et visualisation de la route
</p>
</div>
<PortRouteMap
@ -372,53 +398,51 @@ export default function AdvancedSearchPage() {
</div>
);
const renderStep2 = () => {
const totals = calculateTotals();
return (
const renderStep2 = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">{t('step2.title')}</h2>
<h2 className="text-xl font-semibold text-gray-900">2. Conditionnement</h2>
<button
type="button"
onClick={addPackage}
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
>
{t('step2.addPackage')}
+ Ajouter un colis
</button>
</div>
{searchForm.packages.map((pkg, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">{t('step2.packageNumber', { number: index + 1 })}</h3>
<h3 className="font-medium text-gray-900">Colis #{index + 1}</h3>
{searchForm.packages.length > 1 && (
<button
type="button"
onClick={() => removePackage(index)}
className="text-sm text-red-600 hover:text-red-700"
>
{t('step2.remove')}
Supprimer
</button>
)}
</div>
<div className="grid grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.type')}</label>
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
<select
value={pkg.type}
onChange={e => updatePackage(index, 'type', e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
>
<option value="caisse">{t('step2.packageTypes.caisse')}</option>
<option value="colis">{t('step2.packageTypes.colis')}</option>
<option value="palette">{t('step2.packageTypes.palette')}</option>
<option value="autre">{t('step2.packageTypes.autre')}</option>
<option value="caisse">Caisse</option>
<option value="colis">Colis</option>
<option value="palette">Palette</option>
<option value="autre">Autre</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.quantity')}</label>
<label className="block text-xs font-medium text-gray-700 mb-1">Quantité</label>
<input
type="number"
min="1"
@ -429,7 +453,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.length')}</label>
<label className="block text-xs font-medium text-gray-700 mb-1">L (cm)</label>
<input
type="number"
min="1"
@ -440,7 +464,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.width')}</label>
<label className="block text-xs font-medium text-gray-700 mb-1">l (cm)</label>
<input
type="number"
min="1"
@ -451,7 +475,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.height')}</label>
<label className="block text-xs font-medium text-gray-700 mb-1">H (cm)</label>
<input
type="number"
min="1"
@ -464,7 +488,7 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.weight')}</label>
<label className="block text-xs font-medium text-gray-700 mb-1">Poids (kg)</label>
<input
type="number"
min="1"
@ -481,31 +505,30 @@ export default function AdvancedSearchPage() {
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label className="ml-2 text-sm text-gray-700">{t('step2.stackable')}</label>
<label className="ml-2 text-sm text-gray-700">Gerbable</label>
</div>
</div>
</div>
))}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">{t('step2.summary.title')}</h3>
<h3 className="text-sm font-medium text-blue-900 mb-2">Récapitulatif</h3>
<div className="text-sm text-blue-800 space-y-1">
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
<div>{t('step2.summary.weight', { value: totals.totalWeightKG })}</div>
<div>{t('step2.summary.pallets', { value: totals.totalPallets })}</div>
<div>Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³</div>
<div>Poids total: {calculateTotals().totalWeightKG} kg</div>
<div>Palettes: {calculateTotals().totalPallets}</div>
</div>
</div>
</div>
);
};
const renderStep3 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">{t('step3.title')}</h2>
<h2 className="text-xl font-semibold text-gray-900">3. Options & Services</h2>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.customs.title')}</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Douane Import / Export</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -514,7 +537,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.eurDocument')}</span>
<span className="ml-2 text-sm text-gray-700">EUR 1</span>
</label>
<label className="flex items-center">
<input
@ -523,7 +546,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.t1Document')}</span>
<span className="ml-2 text-sm text-gray-700">T1</span>
</label>
<label className="flex items-center">
<input
@ -532,7 +555,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.customsStop')}</span>
<span className="ml-2 text-sm text-gray-700">Stop douane</span>
</label>
<label className="flex items-center">
<input
@ -543,13 +566,13 @@ export default function AdvancedSearchPage() {
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.exportAssistance')}</span>
<span className="ml-2 text-sm text-gray-700">Assistance export</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.goods.title')}</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Marchandise</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -558,7 +581,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.dangerous')}</span>
<span className="ml-2 text-sm text-gray-700">Marchandise Dangereuse</span>
</label>
<label className="flex items-center">
<input
@ -569,13 +592,13 @@ export default function AdvancedSearchPage() {
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.regulated')}</span>
<span className="ml-2 text-sm text-gray-700">Produits règlementés</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.handling.title')}</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Manutention particulière</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -586,7 +609,7 @@ export default function AdvancedSearchPage() {
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
<span className="ml-2 text-sm text-gray-700">Manutention spéciale</span>
</label>
<label className="flex items-center">
<input
@ -595,7 +618,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.tailgate')}</span>
<span className="ml-2 text-sm text-gray-700">Hayon</span>
</label>
<label className="flex items-center">
<input
@ -604,7 +627,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.straps')}</span>
<span className="ml-2 text-sm text-gray-700">Sangles</span>
</label>
<label className="flex items-center">
<input
@ -613,13 +636,13 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.thermalCover')}</span>
<span className="ml-2 text-sm text-gray-700">Couverture thermique</span>
</label>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.other.title')}</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Autres options</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -628,7 +651,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.appointment')}</span>
<span className="ml-2 text-sm text-gray-700">Rendez-vous livraison</span>
</label>
<label className="flex items-center">
<input
@ -637,7 +660,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.insurance')}</span>
<span className="ml-2 text-sm text-gray-700">Assurance</span>
</label>
</div>
</div>
@ -647,13 +670,15 @@ export default function AdvancedSearchPage() {
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<h1 className="text-3xl font-bold text-gray-900">Recherche Avancée de Tarifs</h1>
<p className="text-sm text-gray-500 mt-1">
{t('subtitle')}
Formulaire complet avec toutes les options de transport
</p>
</div>
{/* Progress Steps */}
<div className="flex items-center justify-center space-x-4">
{[1, 2, 3].map(step => (
<div key={step} className="flex items-center">
@ -675,11 +700,13 @@ export default function AdvancedSearchPage() {
))}
</div>
{/* Form */}
<div className="bg-white rounded-lg shadow p-8">
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{/* Navigation */}
<div className="mt-8 flex items-center justify-between pt-6 border-t">
<button
type="button"
@ -687,7 +714,7 @@ export default function AdvancedSearchPage() {
disabled={currentStep === 1}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('navigation.previous')}
Précédent
</button>
{currentStep < 3 ? (
@ -697,7 +724,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('navigation.next')}
Suivant
</button>
) : (
<button
@ -706,7 +733,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination}
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<Search className="h-5 w-5 mr-2" /> {t('navigation.search')}
<Search className="h-5 w-5 mr-2" /> Rechercher les tarifs
</button>
)}
</div>

View File

@ -1,9 +1,7 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
@ -15,15 +13,13 @@ interface BestOptions {
}
export default function SearchResultsPage() {
const t = useTranslations('dashboard.rateSearch.results');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter();
const searchParams = useSearchParams();
const [results, setResults] = useState<CsvRateSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Parse search parameters from URL
const origin = searchParams.get('origin') || '';
const destination = searchParams.get('destination') || '';
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
@ -53,11 +49,11 @@ export default function SearchResultsPage() {
setResults(response.results);
} catch (err) {
console.error('Search error:', err);
setError(err instanceof Error ? err.message : t('errorGeneric'));
setError(err instanceof Error ? err.message : 'Une erreur est survenue lors de la recherche');
} finally {
setIsLoading(false);
}
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]);
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams]);
useEffect(() => {
if (!origin || !destination || !volumeCBM || !weightKG) {
@ -71,10 +67,12 @@ export default function SearchResultsPage() {
const getBestOptions = (): BestOptions | null => {
if (results.length === 0) return null;
// Filter results by serviceLevel (backend generates 3 offers per rate)
const economic = results.find(r => r.serviceLevel === 'ECONOMIC');
const standard = results.find(r => r.serviceLevel === 'STANDARD');
const rapid = results.find(r => r.serviceLevel === 'RAPID');
// If we have all 3 service levels, return them
if (economic && standard && rapid) {
return {
eco: economic,
@ -83,6 +81,7 @@ export default function SearchResultsPage() {
};
}
// Fallback: if serviceLevel is not present (old endpoint), use sorting
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
@ -96,7 +95,7 @@ export default function SearchResultsPage() {
const bestOptions = getBestOptions();
const formatPrice = (price: number) => {
return new Intl.NumberFormat(dateLocale, {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(price);
@ -108,7 +107,7 @@ export default function SearchResultsPage() {
<div className="max-w-7xl mx-auto">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mb-4"></div>
<p className="text-xl text-gray-700 font-medium">{t('loadingTitle')}</p>
<p className="text-xl text-gray-700 font-medium">Recherche des meilleurs tarifs en cours...</p>
<p className="text-gray-500 mt-2">
{origin} {destination}
</p>
@ -124,13 +123,13 @@ export default function SearchResultsPage() {
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
<h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3>
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={() => router.push('/dashboard/search-advanced')}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
{t('backToSearch')}
Retour à la recherche
</button>
</div>
</div>
@ -146,30 +145,35 @@ export default function SearchResultsPage() {
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
{t('backToSearch')}
Retour à la recherche
</button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
<p className="text-gray-600 mb-4">
{t('noResultsMessage', { origin, destination })}
Aucun tarif ne correspond à votre recherche pour le trajet {origin} {destination}
</p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
<Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')}
</h4>
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
<ul className="text-sm text-gray-700 space-y-2">
<li>{t('suggestionPorts')}</li>
<li>{t('suggestionVolume')}</li>
<li>{t('suggestionWeight')}</li>
<li>
<strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX,
CNSHG, SGSIN (destination)
</li>
<li>
<strong>Volume :</strong> Essayez entre 1 et 200 CBM
</li>
<li>
<strong>Poids :</strong> Essayez entre 100 et 30000 kg
</li>
</ul>
</div>
<button
onClick={() => router.back()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{t('modifySearch')}
Modifier la recherche
</button>
</div>
</div>
@ -179,7 +183,7 @@ export default function SearchResultsPage() {
const optionCards = [
{
type: t('options.economic'),
type: 'Économique',
option: bestOptions?.eco,
colors: {
border: 'border-green-200',
@ -188,10 +192,10 @@ export default function SearchResultsPage() {
button: 'bg-green-600 hover:bg-green-700',
},
icon: <DollarSign className="h-10 w-10 text-green-600" />,
badge: t('options.badgeCheapest'),
badge: 'Le moins cher',
},
{
type: t('options.standard'),
type: 'Standard',
option: bestOptions?.standard,
colors: {
border: 'border-blue-200',
@ -200,10 +204,10 @@ export default function SearchResultsPage() {
button: 'bg-blue-600 hover:bg-blue-700',
},
icon: <Scale className="h-10 w-10 text-blue-600" />,
badge: t('options.badgeBalanced'),
badge: 'Équilibré',
},
{
type: t('options.fast'),
type: 'Rapide',
option: bestOptions?.fast,
colors: {
border: 'border-purple-200',
@ -212,7 +216,7 @@ export default function SearchResultsPage() {
button: 'bg-purple-600 hover:bg-purple-700',
},
icon: <Zap className="h-10 w-10 text-purple-600" />,
badge: t('options.badgeFastest'),
badge: 'Le plus rapide',
},
];
@ -225,23 +229,21 @@ export default function SearchResultsPage() {
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
{t('backToSearch')}
Retour à la recherche
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Résultats de recherche</h1>
<p className="text-gray-600">
<span className="font-semibold">{origin}</span> <span className="font-semibold">{destination}</span>{' '}
{' '}
{palletCount > 0
? t('summaryWithPallets', { volume: volumeCBM, weight: weightKG, count: palletCount })
: t('summary', { volume: volumeCBM, weight: weightKG })}
{volumeCBM} CBM {weightKG} kg
{palletCount > 0 && `${palletCount} palette${palletCount > 1 ? 's' : ''}`}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">{t('ratesFound')}</p>
<p className="text-sm text-gray-500">Tarifs trouvés</p>
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
</div>
</div>
@ -253,7 +255,7 @@ export default function SearchResultsPage() {
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
{t('bestChoices')}
Meilleurs choix pour votre recherche
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@ -280,23 +282,21 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3">
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
<p className="text-sm text-gray-600 mb-1">Prix total</p>
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p>
</div>
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">{t('carrier')}</span>
<span className="text-gray-600">Transporteur :</span>
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900">
{t('transitDays', { days: card.option.transitDays })}
</span>
<span className="text-gray-600">Transit :</span>
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{t('type')}</span>
<span className="text-gray-600">Type :</span>
<span className="font-semibold text-gray-900">{card.option.containerType}</span>
</div>
</div>
@ -309,7 +309,7 @@ export default function SearchResultsPage() {
}}
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
>
{t('select')}
Sélectionner cette option
</button>
</div>
</div>
@ -321,7 +321,7 @@ export default function SearchResultsPage() {
{/* All Results */}
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{t('allResults', { count: results.length })}</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Tous les tarifs disponibles ({results.length})</h2>
<div className="space-y-4">
{results.map((result, index) => (
@ -335,45 +335,39 @@ export default function SearchResultsPage() {
</div>
<div className="text-right">
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p>
<p className="text-sm text-gray-500">{t('totalPrice')}</p>
<p className="text-sm text-gray-500">Prix total</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.base')}</p>
<p className="text-xs text-gray-600 mb-1">Prix de base</p>
<p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.basePrice)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.volume')}</p>
<p className="text-xs text-gray-600 mb-1">Frais volume</p>
<p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.volumeCharge)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.weight')}</p>
<p className="text-xs text-gray-600 mb-1">Frais poids</p>
<p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.weightCharge)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p>
<p className="font-semibold text-gray-900">
{t('transitDays', { days: result.transitDays })}
</p>
<p className="text-xs text-gray-600 mb-1">Délai transit</p>
<p className="font-semibold text-gray-900">{result.transitDays} jours</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600">
<span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span>
{result.hasSurcharges && (
<span className="text-orange-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> {t('surcharges')}
</span>
)}
<span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
</div>
<button
onClick={() => {
@ -382,7 +376,7 @@ export default function SearchResultsPage() {
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{t('selectShort')}
Sélectionner
</button>
</div>
</div>

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