Compare commits

...

2 Commits

Author SHA1 Message Date
David
8ae3d600ea Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m23s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m59s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m37s
CD Preprod / Backend — Integration Tests (push) Successful in 9m57s
CD Preprod / Build Backend (push) Successful in 16m33s
CD Preprod / Build Log Exporter (push) Successful in 1m25s
CD Preprod / Build Frontend (push) Successful in 38m43s
CD Preprod / Deploy to Preprod (push) Successful in 26s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-04-21 19:16:29 +02:00
David
ec0173483a fix language
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
2026-04-21 18:04:02 +02:00
159 changed files with 20128 additions and 14581 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -153,10 +153,7 @@ export class InvitationsController {
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
@ApiResponse({ status: 404, description: 'Invitation not found' })
@ApiResponse({ status: 400, description: 'Invitation already used' })
async cancelInvitation(
@Param('id') id: string,
@CurrentUser() user: UserPayload
): Promise<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,4 +1,5 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Locale } from '@domain/value-objects/locale.vo';
/**
* User payload interface extracted from JWT
@ -10,6 +11,7 @@ export interface UserPayload {
organizationId: string;
firstName: string;
lastName: string;
preferredLanguage?: Locale;
}
/**

View File

@ -0,0 +1,44 @@
/**
* DomainExceptionFilter
*
* Catches any DomainException bubbling up to the HTTP boundary, translates its
* i18nKey/i18nArgs into the caller's locale (resolved by nestjs-i18n) and
* returns a structured JSON error response.
*
* Non-domain errors fall through to NestJS's default handler.
*/
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { I18nService, I18nContext } from 'nestjs-i18n';
import { Response, Request } from 'express';
import { DomainException } from '@domain/exceptions/domain.exception';
import { DEFAULT_LOCALE, Locale, toLocale } from '@domain/value-objects/locale.vo';
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
constructor(private readonly i18n: I18nService<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,12 +1,4 @@
import {
Controller,
Get,
Query,
Res,
UseGuards,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Controller, Get, Query, Res, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -22,7 +14,7 @@ export class LogsController {
constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>(
'LOG_EXPORTER_URL',
'http://xpeditis-log-exporter:3200',
'http://xpeditis-log-exporter:3200'
);
}
@ -39,10 +31,7 @@ export class LogsController {
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
return res.json();
} catch (err: any) {
throw new HttpException(
{ error: err.message },
HttpStatus.BAD_GATEWAY,
);
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
}
}
@ -59,7 +48,7 @@ export class LogsController {
@Query('end') end: string,
@Query('limit') limit: string,
@Query('format') format: string = 'json',
@Res() res: Response,
@Res() res: Response
) {
try {
const params = new URLSearchParams();
@ -71,10 +60,9 @@ export class LogsController {
if (limit) params.set('limit', limit);
params.set('format', format);
const upstream = await fetch(
`${this.logExporterUrl}/api/logs/export?${params}`,
{ signal: AbortSignal.timeout(30000) },
);
const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, {
signal: AbortSignal.timeout(30000),
});
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({}));

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
/**
* DomainException (Base)
*
* Base class for all translatable domain exceptions.
* Exceptions carry an i18n key + optional args so the application-layer
* exception filter can translate them into the caller's locale at the HTTP
* response boundary.
*
* Subclasses should:
* - Pass an i18nKey (e.g. 'error.PORT_NOT_FOUND')
* - Pass i18nArgs for interpolation (e.g. { portCode })
* - Optionally override `status` (HTTP status, default 400)
*/
export type I18nArgs = Record<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,6 +4,7 @@
* All domain exceptions for the Xpeditis platform
*/
export * from './domain.exception';
export * from './invalid-port-code.exception';
export * from './invalid-rate-quote.exception';
export * from './carrier-timeout.exception';

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.
*/
export class PortNotFoundException extends Error {
import { DomainException } from './domain.exception';
export class PortNotFoundException extends DomainException {
constructor(public readonly portCode: string) {
super(`Port not found: ${portCode}`);
this.name = 'PortNotFoundException';
Object.setPrototypeOf(this, PortNotFoundException.prototype);
super('error.PORT_NOT_FOUND', { portCode }, `Port not found: ${portCode}`, 404);
}
}

View File

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

View File

@ -0,0 +1,19 @@
/**
* Locale Value Object
*
* Represents the supported UI / response languages of the platform.
*/
export const SUPPORTED_LOCALES = ['fr', 'en'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'fr';
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export function toLocale(value: unknown, fallback: Locale = DEFAULT_LOCALE): Locale {
return isLocale(value) ? value : fallback;
}

View File

@ -0,0 +1,9 @@
{
"LOGIN_SUCCESS": "Login successful",
"LOGOUT_SUCCESS": "Logout successful",
"REGISTER_SUCCESS": "Registration successful — please verify your email",
"PASSWORD_RESET_SENT": "If the email exists, a reset link has been sent",
"PASSWORD_RESET_SUCCESS": "Password has been reset successfully",
"EMAIL_VERIFIED": "Email verified successfully",
"VERIFICATION_EMAIL_SENT": "Verification email has been sent"
}

View File

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

View File

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

View File

@ -0,0 +1,44 @@
{
"common": {
"greeting": "Hello {firstName}",
"footer": "The Xpeditis team",
"ignoreIfNotYou": "If you did not request this email, you can safely ignore it."
},
"verification": {
"subject": "Verify your email",
"title": "Welcome to Xpeditis!",
"body": "Please confirm your email address by clicking the button below.",
"cta": "Verify my email"
},
"passwordReset": {
"subject": "Reset your password",
"title": "Reset your password",
"body": "Click the button below to set a new password. This link is valid for 1 hour.",
"cta": "Reset my password"
},
"welcome": {
"subject": "Welcome to Xpeditis, {firstName}!",
"title": "Welcome aboard!",
"body": "Your account is ready. Start searching maritime rates and bookings right away.",
"cta": "Go to dashboard"
},
"bookingConfirmation": {
"subject": "Booking {bookingNumber} confirmed",
"title": "Booking Confirmation",
"body": "Your booking {bookingNumber} has been confirmed successfully.",
"details": "Details",
"cta": "View booking"
},
"userInvitation": {
"subject": "You have been invited to join Xpeditis",
"title": "You have been invited",
"body": "{inviterName} has invited you to join {organizationName} on Xpeditis.",
"cta": "Accept invitation"
},
"csvBookingRequest": {
"subject": "New booking request {bookingReference}",
"title": "New booking request",
"body": "A new booking request has been submitted. Please review the details.",
"cta": "Review booking"
}
}

View File

@ -0,0 +1,23 @@
{
"INTERNAL_ERROR": "Internal server error",
"UNAUTHORIZED": "Authentication required",
"FORBIDDEN": "You do not have permission to perform this action",
"NOT_FOUND": "Resource not found",
"CONFLICT": "Conflict",
"RATE_LIMITED": "Too many requests — please try again later",
"PORT_NOT_FOUND": "Port not found: {portCode}",
"PORT_INVALID_CODE": "Invalid port code: {portCode}",
"USER_NOT_FOUND": "User not found",
"USER_EMAIL_TAKEN": "This email is already in use",
"USER_INACTIVE": "User account is inactive",
"USER_EMAIL_NOT_VERIFIED": "Email address not verified",
"ORGANIZATION_NOT_FOUND": "Organization not found",
"INVALID_CREDENTIALS": "Invalid email or password",
"INVALID_TOKEN": "Invalid or expired token",
"BOOKING_NOT_FOUND": "Booking {bookingNumber} not found",
"BOOKING_INVALID_STATUS": "Invalid booking status transition",
"RATE_QUOTE_NOT_FOUND": "Rate quote not found",
"RATE_QUOTE_EXPIRED": "Rate quote has expired",
"CARRIER_NOT_FOUND": "Carrier not found",
"NO_LICENSES_AVAILABLE": "No licenses available for this organization"
}

View File

@ -0,0 +1,30 @@
{
"booking": {
"created": {
"title": "Booking Created",
"message": "Your booking {bookingNumber} has been created successfully."
},
"updated": {
"title": "Booking Updated",
"message": "Booking {bookingNumber} status changed to {status}."
},
"confirmed": {
"title": "Booking Confirmed",
"message": "Your booking {bookingNumber} has been confirmed by the carrier."
},
"rejected": {
"title": "Booking Rejected",
"message": "Your booking {bookingNumber} has been rejected by the carrier."
},
"documentUploaded": {
"title": "Document Uploaded",
"message": "Document \"{documentName}\" has been uploaded for your booking."
}
},
"system": {
"welcome": {
"title": "Welcome to Xpeditis",
"message": "Hi {firstName}, welcome aboard! Start by searching for rates."
}
}
}

View File

@ -0,0 +1,36 @@
{
"booking": {
"title": "BOOKING CONFIRMATION",
"bookingNumber": "Booking Number",
"routeInformation": "Route Information",
"origin": "Origin",
"destination": "Destination",
"shipperInformation": "Shipper Information",
"consigneeInformation": "Consignee Information",
"containerDetails": "Container Details",
"cargoDescription": "Cargo Description",
"totalPrice": "Total Price",
"estimatedDeparture": "Estimated Departure",
"estimatedArrival": "Estimated Arrival",
"carrier": "Carrier",
"status": "Status"
},
"rateQuote": {
"title": "RATE QUOTE COMPARISON",
"quoteNumber": "Quote Number",
"issuedAt": "Issued At",
"validUntil": "Valid Until",
"origin": "Origin",
"destination": "Destination",
"carrier": "Carrier",
"transitTime": "Transit Time",
"containerType": "Container Type",
"baseRate": "Base Rate",
"surcharges": "Surcharges",
"totalPrice": "Total Price"
},
"common": {
"generatedOn": "Generated on {date}",
"page": "Page {current} of {total}"
}
}

View File

@ -0,0 +1,30 @@
{
"EMAIL_REQUIRED": "Email is required",
"EMAIL_INVALID": "Invalid email format",
"PASSWORD_REQUIRED": "Password is required",
"PASSWORD_MIN_LENGTH": "Password must be at least {constraint1} characters",
"PASSWORD_MAX_LENGTH": "Password must be at most {constraint1} characters",
"PASSWORD_PATTERN": "Password must contain uppercase, lowercase, number and special character",
"FIRST_NAME_REQUIRED": "First name is required",
"FIRST_NAME_MIN_LENGTH": "First name must be at least {constraint1} characters",
"LAST_NAME_REQUIRED": "Last name is required",
"LAST_NAME_MIN_LENGTH": "Last name must be at least {constraint1} characters",
"PHONE_INVALID": "Invalid phone number",
"SIREN_PATTERN": "SIREN must be exactly 9 digits",
"SIRET_PATTERN": "SIRET must be exactly 14 digits",
"STREET_MIN_LENGTH": "Street must be at least {constraint1} characters",
"CITY_REQUIRED": "City is required",
"POSTAL_CODE_REQUIRED": "Postal code is required",
"COUNTRY_PATTERN": "Country must be a 2-letter ISO code (e.g., FR, US, CN)",
"FIELD_REQUIRED": "This field is required",
"FIELD_TOO_SHORT": "Must be at least {constraint1} characters",
"FIELD_TOO_LONG": "Must be at most {constraint1} characters",
"NUMBER_MIN": "Must be at least {constraint1}",
"NUMBER_MAX": "Must be at most {constraint1}",
"INVALID_UUID": "Invalid identifier format",
"INVALID_DATE": "Invalid date",
"INVALID_ENUM": "Invalid value — allowed values: {constraint1}",
"INVALID_BOOLEAN": "Must be true or false",
"INVALID_URL": "Invalid URL",
"LOCALE_INVALID": "Language must be 'fr' or 'en'"
}

View File

@ -0,0 +1,9 @@
{
"LOGIN_SUCCESS": "Connexion réussie",
"LOGOUT_SUCCESS": "Déconnexion réussie",
"REGISTER_SUCCESS": "Inscription réussie — veuillez vérifier votre email",
"PASSWORD_RESET_SENT": "Si l'email existe, un lien de réinitialisation a été envoyé",
"PASSWORD_RESET_SUCCESS": "Mot de passe réinitialisé avec succès",
"EMAIL_VERIFIED": "Email vérifié avec succès",
"VERIFICATION_EMAIL_SENT": "Email de vérification envoyé"
}

View File

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

View File

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

View File

@ -0,0 +1,44 @@
{
"common": {
"greeting": "Bonjour {firstName}",
"footer": "L'équipe Xpeditis",
"ignoreIfNotYou": "Si vous n'êtes pas à l'origine de cet email, vous pouvez l'ignorer."
},
"verification": {
"subject": "Vérifiez votre email",
"title": "Bienvenue sur Xpeditis !",
"body": "Veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.",
"cta": "Vérifier mon email"
},
"passwordReset": {
"subject": "Réinitialisez votre mot de passe",
"title": "Réinitialisez votre mot de passe",
"body": "Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe. Ce lien est valide 1 heure.",
"cta": "Réinitialiser mon mot de passe"
},
"welcome": {
"subject": "Bienvenue sur Xpeditis, {firstName} !",
"title": "Bienvenue à bord !",
"body": "Votre compte est prêt. Commencez dès maintenant à rechercher des tarifs maritimes et à réserver.",
"cta": "Accéder au tableau de bord"
},
"bookingConfirmation": {
"subject": "Réservation {bookingNumber} confirmée",
"title": "Confirmation de réservation",
"body": "Votre réservation {bookingNumber} a été confirmée avec succès.",
"details": "Détails",
"cta": "Voir la réservation"
},
"userInvitation": {
"subject": "Vous avez été invité à rejoindre Xpeditis",
"title": "Vous avez été invité",
"body": "{inviterName} vous invite à rejoindre {organizationName} sur Xpeditis.",
"cta": "Accepter l'invitation"
},
"csvBookingRequest": {
"subject": "Nouvelle demande de réservation {bookingReference}",
"title": "Nouvelle demande de réservation",
"body": "Une nouvelle demande de réservation vous est soumise. Veuillez examiner les détails.",
"cta": "Examiner la réservation"
}
}

View File

@ -0,0 +1,23 @@
{
"INTERNAL_ERROR": "Erreur interne du serveur",
"UNAUTHORIZED": "Authentification requise",
"FORBIDDEN": "Vous n'avez pas la permission d'effectuer cette action",
"NOT_FOUND": "Ressource introuvable",
"CONFLICT": "Conflit",
"RATE_LIMITED": "Trop de requêtes — veuillez réessayer plus tard",
"PORT_NOT_FOUND": "Port introuvable : {portCode}",
"PORT_INVALID_CODE": "Code de port invalide : {portCode}",
"USER_NOT_FOUND": "Utilisateur introuvable",
"USER_EMAIL_TAKEN": "Cet email est déjà utilisé",
"USER_INACTIVE": "Le compte utilisateur est inactif",
"USER_EMAIL_NOT_VERIFIED": "Adresse email non vérifiée",
"ORGANIZATION_NOT_FOUND": "Organisation introuvable",
"INVALID_CREDENTIALS": "Email ou mot de passe invalide",
"INVALID_TOKEN": "Jeton invalide ou expiré",
"BOOKING_NOT_FOUND": "Réservation {bookingNumber} introuvable",
"BOOKING_INVALID_STATUS": "Transition de statut de réservation invalide",
"RATE_QUOTE_NOT_FOUND": "Cotation introuvable",
"RATE_QUOTE_EXPIRED": "La cotation a expiré",
"CARRIER_NOT_FOUND": "Transporteur introuvable",
"NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation"
}

View File

@ -0,0 +1,30 @@
{
"booking": {
"created": {
"title": "Réservation créée",
"message": "Votre réservation {bookingNumber} a été créée avec succès."
},
"updated": {
"title": "Réservation mise à jour",
"message": "Le statut de la réservation {bookingNumber} est passé à {status}."
},
"confirmed": {
"title": "Réservation confirmée",
"message": "Votre réservation {bookingNumber} a été confirmée par le transporteur."
},
"rejected": {
"title": "Réservation refusée",
"message": "Votre réservation {bookingNumber} a été refusée par le transporteur."
},
"documentUploaded": {
"title": "Document ajouté",
"message": "Le document « {documentName} » a été ajouté à votre réservation."
}
},
"system": {
"welcome": {
"title": "Bienvenue sur Xpeditis",
"message": "Bonjour {firstName}, bienvenue à bord ! Commencez par rechercher des tarifs."
}
}
}

View File

@ -0,0 +1,36 @@
{
"booking": {
"title": "CONFIRMATION DE RÉSERVATION",
"bookingNumber": "Numéro de réservation",
"routeInformation": "Informations de route",
"origin": "Origine",
"destination": "Destination",
"shipperInformation": "Expéditeur",
"consigneeInformation": "Destinataire",
"containerDetails": "Détails du conteneur",
"cargoDescription": "Description de la cargaison",
"totalPrice": "Prix total",
"estimatedDeparture": "Départ estimé",
"estimatedArrival": "Arrivée estimée",
"carrier": "Transporteur",
"status": "Statut"
},
"rateQuote": {
"title": "COMPARAISON DE COTATIONS",
"quoteNumber": "Numéro de cotation",
"issuedAt": "Émis le",
"validUntil": "Valide jusqu'au",
"origin": "Origine",
"destination": "Destination",
"carrier": "Transporteur",
"transitTime": "Temps de transit",
"containerType": "Type de conteneur",
"baseRate": "Tarif de base",
"surcharges": "Surtaxes",
"totalPrice": "Prix total"
},
"common": {
"generatedOn": "Généré le {date}",
"page": "Page {current} sur {total}"
}
}

View File

@ -0,0 +1,30 @@
{
"EMAIL_REQUIRED": "L'email est requis",
"EMAIL_INVALID": "Format d'email invalide",
"PASSWORD_REQUIRED": "Le mot de passe est requis",
"PASSWORD_MIN_LENGTH": "Le mot de passe doit contenir au moins {constraint1} caractères",
"PASSWORD_MAX_LENGTH": "Le mot de passe doit contenir au plus {constraint1} caractères",
"PASSWORD_PATTERN": "Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial",
"FIRST_NAME_REQUIRED": "Le prénom est requis",
"FIRST_NAME_MIN_LENGTH": "Le prénom doit contenir au moins {constraint1} caractères",
"LAST_NAME_REQUIRED": "Le nom est requis",
"LAST_NAME_MIN_LENGTH": "Le nom doit contenir au moins {constraint1} caractères",
"PHONE_INVALID": "Numéro de téléphone invalide",
"SIREN_PATTERN": "Le SIREN doit contenir exactement 9 chiffres",
"SIRET_PATTERN": "Le SIRET doit contenir exactement 14 chiffres",
"STREET_MIN_LENGTH": "L'adresse doit contenir au moins {constraint1} caractères",
"CITY_REQUIRED": "La ville est requise",
"POSTAL_CODE_REQUIRED": "Le code postal est requis",
"COUNTRY_PATTERN": "Le pays doit être un code ISO à 2 lettres (ex. FR, US, CN)",
"FIELD_REQUIRED": "Ce champ est requis",
"FIELD_TOO_SHORT": "Doit contenir au moins {constraint1} caractères",
"FIELD_TOO_LONG": "Doit contenir au plus {constraint1} caractères",
"NUMBER_MIN": "Doit être supérieur ou égal à {constraint1}",
"NUMBER_MAX": "Doit être inférieur ou égal à {constraint1}",
"INVALID_UUID": "Format d'identifiant invalide",
"INVALID_DATE": "Date invalide",
"INVALID_ENUM": "Valeur invalide — valeurs autorisées : {constraint1}",
"INVALID_BOOLEAN": "Doit être vrai ou faux",
"INVALID_URL": "URL invalide",
"LOCALE_INVALID": "La langue doit être 'fr' ou 'en'"
}

View File

@ -73,7 +73,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
this.buildTransporter(ip, host);
return;
} catch (err: any) {
this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
this.logger.warn(
`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`
);
}
}
@ -87,9 +89,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
private resolveViaDoH(hostname: string): Promise<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);
@ -136,7 +138,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
);
this.transporter.verify((error) => {
this.transporter.verify(error => {
if (error) {
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
} else {
@ -148,8 +150,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
async send(options: EmailOptions): Promise<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

@ -0,0 +1,19 @@
/**
* UserPreferenceResolver
*
* nestjs-i18n resolver that reads the authenticated user's preferredLanguage
* from the request (populated by JwtAuthGuard). Highest priority in the chain.
*/
import { Injectable, ExecutionContext } from '@nestjs/common';
import { I18nResolver } from 'nestjs-i18n';
import { isLocale } from '@domain/value-objects/locale.vo';
@Injectable()
export class UserPreferenceResolver implements I18nResolver {
resolve(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
const preferred = request?.user?.preferredLanguage;
return isLocale(preferred) ? preferred : undefined;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
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,12 +1,14 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { VersioningType } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
import { DomainExceptionFilter } from './application/filters/domain-exception.filter';
import type { Request, Response, NextFunction } from 'express';
async function bootstrap() {
@ -42,9 +44,9 @@ async function bootstrap() {
type: VersioningType.URI,
});
// Global validation pipe
// Global validation pipe — i18n-aware (messages translated to caller locale)
app.useGlobalPipes(
new ValidationPipe({
new I18nValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
@ -54,6 +56,15 @@ async function bootstrap() {
})
);
// Global exception filters — each filter declares its target via @Catch(),
// so they don't overlap: DomainExceptionFilter handles DomainException,
// I18nValidationExceptionFilter handles class-validator errors.
const i18nService = app.get(I18nService) as I18nService<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,7 +1,8 @@
'use client';
import { useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView } from 'framer-motion';
import {
Ship,
@ -13,10 +14,42 @@ 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);
@ -31,117 +64,6 @@ 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: {
@ -188,21 +110,19 @@ 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">Notre histoire</span>
<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">
Révolutionner le fret maritime,
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
une réservation à la fois
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
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.
{t('intro')}
</p>
</motion.div>
</div>
@ -234,11 +154,9 @@ 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">Notre Mission</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">
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.
{t('mission.body')}
</p>
</motion.div>
@ -249,11 +167,9 @@ 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">Notre Vision</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">
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.
{t('vision.body')}
</p>
</motion.div>
</motion.div>
@ -269,9 +185,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={index}
key={stat.key}
variants={itemVariants}
className="text-center"
>
@ -283,7 +199,7 @@ export default function AboutPage() {
>
{stat.value}
</motion.div>
<div className="text-gray-600 font-medium">{stat.label}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -299,9 +215,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">Nos Valeurs</h2>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Les principes qui guident chacune de nos décisions
{t('valuesSubtitle')}
</p>
</motion.div>
@ -311,11 +227,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, index) => {
{VALUES.map((value) => {
const IconComponent = value.icon;
return (
<motion.div
key={index}
key={value.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -325,8 +241,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">{value.title}</h3>
<p className="text-gray-600">{value.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
</motion.div>
);
})}
@ -343,14 +259,13 @@ 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">Notre Parcours</h2>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
De la startup au leader européen du fret maritime digital
{t('timelineSubtitle')}
</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 }}
@ -362,9 +277,9 @@ export default function AboutPage() {
</div>
<div className="space-y-12">
{timeline.map((item, index) => (
{TIMELINE_YEARS.map((year, index) => (
<motion.div
key={index}
key={year}
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, amount: 0.4 }}
@ -375,14 +290,13 @@ 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">{item.year}</span>
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
</div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
<p className="text-gray-600">{item.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
</div>
</div>
{/* Animated center dot */}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
<motion.div
initial={{ scale: 0 }}
@ -410,9 +324,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">Notre Équipe</h2>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des experts passionnés par le maritime et la technologie
{t('teamSubtitle')}
</p>
</motion.div>
@ -422,9 +336,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, index) => (
{TEAM.map((member) => (
<motion.div
key={index}
key={member.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
@ -444,8 +358,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">{member.role}</p>
<p className="text-gray-600 text-sm">{member.bio}</p>
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
</div>
</motion.div>
))}
@ -463,25 +377,24 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Rejoignez l'aventure Xpeditis
{t('cta.title')}
</h2>
<p className="text-xl text-white/80 mb-10">
Que vous soyez transitaire à la recherche d'une solution moderne ou talent souhaitant
rejoindre une équipe passionnée, nous avons hâte de vous rencontrer.
{t('cta.body')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<Link
href="/register"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
>
<span>Créer un compte</span>
<span>{t('cta.createAccount')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
>
Voir les offres d'emploi
{t('cta.viewCareers')}
</Link>
</div>
</motion.div>

View File

@ -1,7 +1,8 @@
'use client';
import { useState, useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView } from 'framer-motion';
import {
Ship,
@ -15,11 +16,34 @@ 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 [selectedCategory, setSelectedCategory] = useState('all');
const t = useTranslations('marketing.blog');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
const [searchQuery, setSearchQuery] = useState('');
const heroRef = useRef(null);
@ -30,121 +54,14 @@ export default function BlogPage() {
const isArticlesInView = useInView(articlesRef, { once: true });
const isCategoriesInView = useInView(categoriesRef, { once: true });
const categories = [
{ value: 'all', label: 'Tous les articles', icon: BookOpen },
{ value: 'industry', label: 'Industrie maritime', icon: Ship },
{ value: 'technology', label: 'Technologie', icon: TrendingUp },
{ value: 'guides', label: 'Guides pratiques', icon: FileText },
{ value: 'news', label: 'Actualités', icon: Globe },
];
const featuredArticle = {
id: 1,
title: 'L\'avenir du fret maritime : comment l\'IA transforme la logistique',
excerpt:
'Découvrez comment l\'intelligence artificielle révolutionne la gestion des expéditions maritimes et optimise les chaînes d\'approvisionnement mondiales.',
category: 'technology',
author: 'Marie Lefebvre',
authorRole: 'CTO',
date: '15 janvier 2025',
readTime: '8 min',
image: '/assets/images/blog/featured.jpg',
tags: ['IA', 'Innovation', 'Logistique'],
};
const articles = [
{
id: 2,
title: 'Guide complet des Incoterms 2020 pour le transport maritime',
excerpt:
'Tout ce que vous devez savoir sur les règles Incoterms et leur application dans le fret maritime international.',
category: 'guides',
author: 'Thomas Martin',
date: '10 janvier 2025',
readTime: '12 min',
image: '/assets/images/blog/incoterms.jpg',
tags: ['Incoterms', 'Guide', 'Commerce international'],
},
{
id: 3,
title: 'Comment optimiser vos coûts de transport maritime en 2025',
excerpt:
'Stratégies et conseils pratiques pour réduire vos dépenses logistiques sans compromettre la qualité de service.',
category: 'guides',
author: 'Sophie Bernard',
date: '8 janvier 2025',
readTime: '6 min',
image: '/assets/images/blog/costs.jpg',
tags: ['Optimisation', 'Coûts', 'Stratégie'],
},
{
id: 4,
title: 'Les plus grands ports européens : classement 2025',
excerpt:
'Analyse des performances des principaux ports européens et tendances du trafic conteneurisé.',
category: 'industry',
author: 'Jean-Pierre Durand',
date: '5 janvier 2025',
readTime: '10 min',
image: '/assets/images/blog/ports.jpg',
tags: ['Ports', 'Europe', 'Statistiques'],
},
{
id: 5,
title: 'Xpeditis lève 15M€ pour accélérer son expansion',
excerpt:
'Notre série A nous permet de renforcer notre équipe et d\'étendre notre présence en Europe.',
category: 'news',
author: 'Jean-Pierre Durand',
date: '3 janvier 2025',
readTime: '4 min',
image: '/assets/images/blog/funding.jpg',
tags: ['Financement', 'Croissance', 'Xpeditis'],
},
{
id: 6,
title: 'Décarbonation du transport maritime : où en sommes-nous ?',
excerpt:
'État des lieux des initiatives environnementales dans le secteur maritime et perspectives pour 2030.',
category: 'industry',
author: 'Claire Moreau',
date: '28 décembre 2024',
readTime: '9 min',
image: '/assets/images/blog/green.jpg',
tags: ['Environnement', 'Décarbonation', 'Durabilité'],
},
{
id: 7,
title: 'APIs et intégrations : comment connecter votre TMS à Xpeditis',
excerpt:
'Guide technique pour intégrer notre plateforme avec vos systèmes de gestion existants.',
category: 'technology',
author: 'Alexandre Petit',
date: '22 décembre 2024',
readTime: '15 min',
image: '/assets/images/blog/api.jpg',
tags: ['API', 'Intégration', 'Technique'],
},
{
id: 8,
title: 'Les documents essentiels pour l\'export maritime',
excerpt:
'Check-list complète des documents requis pour vos expéditions maritimes internationales.',
category: 'guides',
author: 'Thomas Martin',
date: '18 décembre 2024',
readTime: '7 min',
image: '/assets/images/blog/documents.jpg',
tags: ['Documents', 'Export', 'Douane'],
},
];
const filteredArticles = articles.filter((article) => {
const 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 === '' ||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
return categoryMatch && searchMatch;
});
@ -194,20 +111,19 @@ 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">Blog Xpeditis</span>
<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">
Actualités & Insights
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
du fret maritime
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Restez informé des dernières tendances du transport maritime, découvrez nos guides
pratiques et suivez l'actualité de Xpeditis.
{t('intro')}
</p>
{/* Search Bar */}
@ -221,7 +137,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="Rechercher un article..."
placeholder={t('searchPlaceholder')}
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"
@ -251,13 +167,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.value;
const isActive = selectedCategory === category.key;
return (
<button
key={category.value}
onClick={() => setSelectedCategory(category.value)}
key={category.key}
onClick={() => setSelectedCategory(category.key)}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
isActive
? 'bg-brand-turquoise text-white'
@ -265,7 +181,7 @@ export default function BlogPage() {
}`}
>
<IconComponent className="w-4 h-4" />
<span className="font-medium">{category.label}</span>
<span className="font-medium">{t(`categories.${category.key}`)}</span>
</button>
);
})}
@ -282,7 +198,7 @@ export default function BlogPage() {
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<Link href={`/blog/${featuredArticle.id}`}>
<Link href="/blog/1">
<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">
@ -293,36 +209,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">
À la une
{t('featuredBadge')}
</span>
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{categories.find((c) => c.value === featuredArticle.category)?.label}
{t('categories.technology')}
</span>
</div>
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
{featuredArticle.title}
{t('featured.title')}
</h2>
<p className="text-lg text-white/80 mb-6">{featuredArticle.excerpt}</p>
<p className="text-lg text-white/80 mb-6">{t('featured.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>{featuredArticle.author}</span>
<span>{t('featured.author')}</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" />
<span>{featuredArticle.date}</span>
<span>{t('featured.date')}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{featuredArticle.readTime}</span>
<span>{t('featured.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>Lire l'article</span>
<span>{t('readArticle')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
</div>
@ -342,15 +258,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">Tous les articles</h2>
<span className="text-gray-500">{filteredArticles.length} articles</span>
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</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">Aucun article trouvé</h3>
<p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
<p className="text-gray-500">{t('noResults.body')}</p>
</div>
) : (
<motion.div
@ -367,17 +283,19 @@ 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">
{categories.find((c) => c.value === article.category)?.label}
{t(`categories.${article.category}`)}
</span>
</div>
</div>
<div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
{article.title}
{t(`articles.${article.key}.title` as any)}
</h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{article.excerpt}</p>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">
{t(`articles.${article.key}.excerpt` as any)}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{article.tags.map((tag) => (
@ -395,13 +313,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>{article.author}</span>
<span>{t(`articles.${article.key}.author` as any)}</span>
</div>
<div className="flex items-center space-x-4">
<span>{article.date}</span>
<span>{t(`articles.${article.key}.date` as any)}</span>
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>{article.readTime}</span>
<span>{t(`articles.${article.key}.readTime` as any)}</span>
</span>
</div>
</div>
@ -423,7 +341,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">
Charger plus d'articles
{t('loadMore')}
</button>
</motion.div>
)}
@ -440,28 +358,27 @@ export default function BlogPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-white mb-6">
Restez informé
{t('newsletter.title')}
</h2>
<p className="text-xl text-white/80 mb-10">
Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités
du fret maritime directement dans votre boîte mail.
{t('newsletter.body')}
</p>
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<input
type="email"
placeholder="votre@email.com"
placeholder={t('newsletter.emailPlaceholder')}
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
/>
<button
type="submit"
className="w-full sm:w-auto px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold flex items-center justify-center space-x-2"
>
<span>S'abonner</span>
<span>{t('newsletter.subscribe')}</span>
<ArrowRight className="w-5 h-5" />
</button>
</form>
<p className="text-white/50 text-sm mt-4">
En vous inscrivant, vous acceptez notre politique de confidentialité. Désabonnement possible à tout moment.
{t('newsletter.disclaimer')}
</p>
</motion.div>
</div>

View File

@ -8,21 +8,21 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingConfirmPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('bookingPortal.confirm');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<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,31 +33,29 @@ export default function BookingConfirmPage() {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Une erreur est survenue lors de l\'acceptation');
setError(t('errorGeneric'));
}
} finally {
setIsLoading(false);
setIsAccepting(false);
}
}, [token]);
}, [token, t]);
useEffect(() => {
if (!token) {
setError('Token de confirmation invalide');
setError(t('tokenInvalid'));
setIsLoading(false);
return;
}
// Auto-accept the booking
handleAccept();
}, [token, handleAccept]);
}, [token, handleAccept, t]);
if (isLoading) {
return (
<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">Confirmation en cours...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -84,24 +82,24 @@ export default function BookingConfirmPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Erreur de confirmation
{t('errorTitle')}
</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>Raisons possibles :</strong>
<strong>{t('errorReasonsTitle')}</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<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>
<li>{t('errorReason1')}</li>
<li>{t('errorReason2')}</li>
<li>{t('errorReason3')}</li>
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
{t('errorContact')}
</p>
</div>
</div>
@ -133,67 +131,68 @@ 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">
Demande acceptée !
{t('successTitle')}
</h1>
<p className="text-lg text-gray-600 mb-2">
Merci d'avoir accepté cette demande de transport.
{t('successHeadline')}
</p>
<p className="text-gray-500">
Le client a é notifié par email.
{t('successBody')}
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif de la réservation
{t('summaryTitle')}
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">ID Réservation</span>
<span className="text-gray-600">{t('labels.bookingId')}</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">Trajet</span>
<span className="text-gray-600">{t('labels.route')}</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">Volume</span>
<span className="text-gray-600">{t('labels.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">Poids</span>
<span className="text-gray-600">{t('labels.weight')}</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">Palettes</span>
<span className="text-gray-600">{t('labels.pallets')}</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">Type de conteneur</span>
<span className="text-gray-600">{t('labels.containerType')}</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">Temps de transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
<span className="text-gray-600">{t('labels.transitDays')}</span>
<span className="font-semibold text-gray-900">
{t('transitDaysValue', { count: booking.transitDays })}
</span>
</div>
<div className="flex justify-between py-3">
<span className="text-gray-600 text-lg">Prix</span>
<span className="text-gray-600 text-lg">{t('labels.price')}</span>
<div className="text-right">
<div className="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD'
@ -213,7 +212,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">Notes :</p>
<p className="text-sm text-gray-600 mb-1">{t('labels.notes')}</p>
<p className="text-gray-800">{booking.notes}</p>
</div>
)}
@ -225,19 +224,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>
Prochaines étapes
{t('nextStepsTitle')}
</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<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>
<li>{t('nextStep1')}</li>
<li>{t('nextStep2')}</li>
<li>{t('nextStep3')}</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">Documents fournis</h3>
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
<div className="space-y-2">
{booking.documents.map((doc, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200">
@ -256,7 +255,7 @@ export default function BookingConfirmPage() {
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Télécharger
{t('labels.download')}
</a>
</div>
))}
@ -266,7 +265,7 @@ export default function BookingConfirmPage() {
{/* Contact Info */}
<div className="text-center text-sm text-gray-500">
<p>Pour toute question, contactez-nous à</p>
<p>{tCommon('supportPrompt')}</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com
</a>

View File

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

View File

@ -1,7 +1,8 @@
'use client';
import { useState, useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView, AnimatePresence } from 'framer-motion';
import {
Briefcase,
@ -22,12 +23,63 @@ 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 [selectedDepartment, setSelectedDepartment] = useState('all');
const [selectedLocation, setSelectedLocation] = useState('all');
const t = useTranslations('marketing.careers');
const [selectedDepartment, setSelectedDepartment] = useState<DepartmentValue>('all');
const [selectedLocation, setSelectedLocation] = useState<LocationValue>('all');
const [expandedJob, setExpandedJob] = useState<number | null>(null);
const heroRef = useRef(null);
@ -40,161 +92,7 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { once: true });
const isCultureInView = useInView(cultureRef, { once: true });
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 filteredJobs = JOBS.filter((job) => {
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
return departmentMatch && locationMatch;
@ -246,20 +144,19 @@ 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">Rejoignez-nous</span>
<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">
Construisons ensemble
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
le futur du maritime
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
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.
{t('intro')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
@ -267,14 +164,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>Voir les offres</span>
<span>{t('viewJobs')}</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"
>
En savoir plus
{t('learnMore')}
</Link>
</div>
</motion.div>
@ -295,14 +192,9 @@ 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">
{[
{ 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) => (
{STATS.map((stat, index) => (
<motion.div
key={index}
key={stat.key}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@ -310,7 +202,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">{stat.label}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -327,10 +219,10 @@ export default function CareersPage() {
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Pourquoi nous rejoindre ?
{t('benefitsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Nous investissons dans le bien-être et le développement de nos équipes
{t('benefitsSubtitle')}
</p>
</motion.div>
@ -340,11 +232,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, index) => {
{BENEFITS.map((benefit) => {
const IconComponent = benefit.icon;
return (
<motion.div
key={index}
key={benefit.key}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -352,8 +244,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">{benefit.title}</h3>
<p className="text-gray-600">{benefit.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
</motion.div>
);
})}
@ -371,21 +263,15 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Notre culture
{t('cultureTitle')}
</h2>
<p className="text-xl text-white/80 mb-8">
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.
{t('cultureBody')}
</p>
<ul className="space-y-4">
{[
'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) => (
{CULTURE_ITEMS.map((itemKey, index) => (
<motion.li
key={index}
key={itemKey}
initial={{ opacity: 0, x: -20 }}
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }}
@ -394,7 +280,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>{item}</span>
<span>{t(`culture.${itemKey}`)}</span>
</motion.li>
))}
</ul>
@ -429,10 +315,10 @@ export default function CareersPage() {
className="text-center mb-12"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Nos offres d'emploi
{t('jobsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Trouvez le poste qui correspond à vos ambitions
{t('jobsSubtitle')}
</p>
</motion.div>
@ -446,12 +332,12 @@ export default function CareersPage() {
<div className="relative">
<select
value={selectedDepartment}
onChange={(e) => setSelectedDepartment(e.target.value)}
onChange={(e) => setSelectedDepartment(e.target.value as DepartmentValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
>
{departments.map((dept) => (
<option key={dept.value} value={dept.value}>
{dept.label}
{DEPARTMENT_VALUES.map((value) => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
</option>
))}
</select>
@ -460,12 +346,12 @@ export default function CareersPage() {
<div className="relative">
<select
value={selectedLocation}
onChange={(e) => setSelectedLocation(e.target.value)}
onChange={(e) => setSelectedLocation(e.target.value as LocationValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
>
{locations.map((loc) => (
<option key={loc.value} value={loc.value}>
{loc.label}
{LOCATION_VALUES.map((value) => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
</option>
))}
</select>
@ -483,8 +369,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">Aucune offre trouvée</h3>
<p className="text-gray-500">Essayez de modifier vos filtres</p>
<h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3>
<p className="text-gray-500">{t('noJobs.body')}</p>
</div>
) : (
filteredJobs.map((job) => {
@ -507,15 +393,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">{job.title}</h3>
<h3 className="text-xl font-bold text-brand-navy">{t(`jobs.${job.key}.title`)}</h3>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" />
<span>{job.department}</span>
<span>{t(`departments.${job.department}` as any)}</span>
</span>
<span className="flex items-center space-x-1">
<MapPin className="w-4 h-4" />
<span>{job.location}</span>
<span>{t(`locations.${job.location}` as any)}</span>
</span>
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
@ -528,7 +414,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">
Remote OK
{t('jobCard.remote')}
</span>
)}
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
@ -554,13 +440,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">{job.description}</p>
<h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4>
<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>
<ul className="space-y-2 mb-6">
{job.requirements.map((req, index) => (
<li key={index} className="flex items-start space-x-2 text-gray-600">
{JOB_REQ_KEYS.map((reqKey) => (
<li key={reqKey} className="flex items-start space-x-2 text-gray-600">
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
<span>{req}</span>
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
</li>
))}
</ul>
@ -569,11 +455,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>Postuler</span>
<span>{t('jobCard.apply')}</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">
En savoir plus
{t('jobCard.learnMore')}
</button>
</div>
</div>
@ -598,17 +484,16 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-brand-navy mb-6">
Pas de poste correspondant ?
{t('cta.title')}
</h2>
<p className="text-xl text-gray-600 mb-10">
Envoyez-nous une candidature spontanée ! Nous sommes toujours à la recherche de
talents passionnés pour rejoindre notre aventure.
{t('cta.body')}
</p>
<Link
href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
>
<span>Candidature spontanée</span>
<span>{t('cta.spontaneous')}</span>
<ArrowRight className="w-5 h-5" />
</Link>
</motion.div>

View File

@ -1,13 +1,16 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
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);
@ -18,20 +21,18 @@ export default function CarrierAcceptPage() {
useEffect(() => {
const acceptBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) {
return;
}
hasCalledApi.current = true;
if (!token) {
setError('Token manquant');
setError(t('common.tokenMissing'));
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',
@ -45,17 +46,17 @@ export default function CarrierAcceptPage() {
try {
errorData = await response.json();
} catch (e) {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking';
let errorMessage = errorData.message || t('accept.errorFallback');
if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) {
errorMessage = 'Ce booking a déjà été accepté.';
errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('status REJECTED')) {
errorMessage = 'Ce booking a déjà été refusé.';
errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
errorMessage = t('common.bookingNotFound');
}
throw new Error(errorMessage);
@ -63,7 +64,6 @@ 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 : 'Erreur lors de l\'acceptation');
setError(err instanceof Error ? err.message : t('accept.errorGeneric'));
setLoading(false);
}
};
acceptBooking();
}, [token, router]);
}, [token, router, t]);
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">
Traitement en cours...
{t('accept.loadingTitle')}
</h1>
<p className="text-gray-600">
Nous traitons votre acceptation.
{t('accept.loadingMessage')}
</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">Erreur</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</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"
>
Retour à l'accueil
{t('common.backHome')}
</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">
Merci !
{t('accept.thanksTitle')}
</h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
<p className="text-green-800 font-medium text-lg mb-2">
Votre acceptation a bien é prise en compte
{t('accept.successHeadline')}
</p>
<p className="text-green-700 text-sm">
Nous vous remercions d'avoir accepté cette demande de transport.
{t('accept.successBody')}
</p>
</div>
<p className="text-gray-500 text-sm mb-4">
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
{t('common.redirecting', { countdown })}
</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"
>
Retour à l'accueil
{t('common.backHome')}
</button>
</div>
</div>

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import {
FileText,
@ -55,13 +56,13 @@ interface AccessRequirements {
status: string;
}
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 DOCUMENT_TYPE_KEYS = [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
] as const;
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
@ -80,6 +81,7 @@ 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);
@ -96,10 +98,17 @@ 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('Lien invalide');
setError(t('linkInvalid'));
setLoading(false);
return;
}
@ -120,13 +129,13 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors du chargement';
const errorMessage = errorData.message || t('loadError');
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
throw new Error(t('bookingNotFound'));
}
throw new Error(errorMessage);
@ -135,16 +144,12 @@ export default function CarrierDocumentsPage() {
const reqData: AccessRequirements = await response.json();
setRequirements(reqData);
// If booking is not accepted yet
if (reqData.status !== 'ACCEPTED') {
setError(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
setError(t('notAcceptedYet'));
setLoading(false);
return;
}
// If no password required, fetch documents directly
if (!reqData.requiresPassword) {
await fetchDocumentsWithoutPassword();
} else {
@ -152,7 +157,7 @@ export default function CarrierDocumentsPage() {
}
} catch (err) {
console.error('Error checking requirements:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setError(err instanceof Error ? err.message : t('loadError'));
setLoading(false);
}
};
@ -172,22 +177,19 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
const errorMessage = errorData.message || t('loadDocsError');
if (
errorMessage.includes('pas encore été acceptée') ||
errorMessage.includes('not accepted')
) {
throw new Error(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
throw new Error(t('notAcceptedYet'));
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
throw new Error(t('bookingNotFound'));
} 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;
@ -201,7 +203,7 @@ export default function CarrierDocumentsPage() {
setLoading(false);
} catch (err) {
console.error('Error fetching documents:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setError(err instanceof Error ? err.message : t('loadError'));
setLoading(false);
}
};
@ -225,17 +227,17 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors de la vérification';
const errorMessage = errorData.message || t('verifyError');
if (
response.status === 401 ||
errorMessage.includes('incorrect') ||
errorMessage.includes('invalid')
) {
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
setPasswordError(t('passwordIncorrect'));
setVerifying(false);
return;
}
@ -248,7 +250,7 @@ export default function CarrierDocumentsPage() {
setVerifying(false);
} catch (err) {
console.error('Error verifying password:', err);
setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
setPasswordError(err instanceof Error ? err.message : t('verifyError'));
setVerifying(false);
}
};
@ -262,7 +264,7 @@ export default function CarrierDocumentsPage() {
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setPasswordError('Veuillez entrer le mot de passe');
setPasswordError(t('passwordMissing'));
return;
}
fetchDocumentsWithPassword(password.trim());
@ -272,13 +274,11 @@ 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('Erreur lors du téléchargement. Veuillez réessayer.');
alert(t('downloadError'));
} 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">Chargement...</h1>
<p className="text-gray-600">Veuillez patienter</p>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1>
<p className="text-gray-600">{t('loadingHint')}</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">Erreur</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('errorTitle')}</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"
>
Réessayer
{t('retry')}
</button>
</div>
</div>
@ -335,14 +335,13 @@ 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">Accès sécurisé</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<p className="text-gray-600">
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
documents.
{t('password.intro')}
</p>
{requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500">
Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
{t('password.bookingLabel')} <span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p>
)}
</div>
@ -350,7 +349,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">
Mot de passe
{t('password.passwordLabel')}
</label>
<div className="relative">
<input
@ -358,7 +357,7 @@ export default function CarrierDocumentsPage() {
id="password"
value={password}
onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder="Ex: A3B7K9"
placeholder={t('password.passwordPlaceholder')}
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
@ -387,12 +386,12 @@ export default function CarrierDocumentsPage() {
{verifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Vérification...
{t('password.verifying')}
</>
) : (
<>
<Lock className="w-5 h-5" />
Accéder aux documents
{t('password.submit')}
</>
)}
</button>
@ -400,10 +399,9 @@ 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> trouver le mot de passe ?</strong>
<strong>{t('password.helpTitle')}</strong>
<br />
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.
{t('password.helpBody')}
</p>
</div>
</div>
@ -433,7 +431,7 @@ export default function CarrierDocumentsPage() {
onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
>
Actualiser
{t('header.refresh')}
</button>
</div>
</header>
@ -449,7 +447,7 @@ export default function CarrierDocumentsPage() {
</div>
{booking.bookingNumber && (
<p className="text-center text-white/70 text-sm mt-1">
N° {booking.bookingNumber}
{t('summary.bookingNumberPrefix')} {booking.bookingNumber}
</p>
)}
</div>
@ -458,33 +456,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">Volume</p>
<p className="text-xs text-gray-500">{t('summary.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">Poids</p>
<p className="text-xs text-gray-500">{t('summary.weight')}</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">Transit</p>
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
<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>
</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">Type</p>
<p className="text-xs text-gray-500">{t('summary.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">
Transporteur:{' '}
{t('summary.carrierLabel')}{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span>
<span className="text-gray-500">
Ref:{' '}
{t('summary.refLabel')}{' '}
<span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
@ -498,16 +496,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" />
Documents ({documents.length})
{t('list.heading', { count: 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">Aucun document disponible pour le moment.</p>
<p className="text-gray-600">{t('list.empty')}</p>
<p className="text-gray-500 text-sm mt-1">
Les documents apparaîtront ici une fois ajoutés.
{t('list.emptyHint')}
</p>
</div>
) : (
@ -523,7 +521,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">
{documentTypeLabels[doc.type] || doc.type}
{getDocumentTypeLabel(doc.type)}
</span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
</div>
@ -543,7 +541,7 @@ export default function CarrierDocumentsPage() {
) : (
<>
<Download className="w-4 h-4" />
<span>Télécharger</span>
<span>{t('list.download')}</span>
</>
)}
</button>
@ -555,13 +553,13 @@ export default function CarrierDocumentsPage() {
{/* Info */}
<p className="mt-6 text-center text-sm text-gray-500">
Cette page affiche toujours les documents les plus récents de la réservation.
{t('footerNote')}
</p>
</main>
{/* Footer */}
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
<p>{t('footer', { year: new Date().getFullYear() })}</p>
</footer>
</div>
);

View File

@ -1,13 +1,16 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
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);
@ -18,20 +21,18 @@ export default function CarrierRejectPage() {
useEffect(() => {
const rejectBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) {
return;
}
hasCalledApi.current = true;
if (!token) {
setError('Token manquant');
setError(t('common.tokenMissing'));
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',
@ -45,17 +46,17 @@ export default function CarrierRejectPage() {
try {
errorData = await response.json();
} catch (e) {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
let errorMessage = errorData.message || 'Erreur lors du refus du booking';
let errorMessage = errorData.message || t('reject.errorFallback');
if (errorMessage.includes('status REJECTED')) {
errorMessage = 'Ce booking a déjà été refusé.';
errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('status ACCEPTED')) {
errorMessage = 'Ce booking a déjà été accepté.';
errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
errorMessage = t('common.bookingNotFound');
}
throw new Error(errorMessage);
@ -63,7 +64,6 @@ 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 : 'Erreur lors du refus');
setError(err instanceof Error ? err.message : t('reject.errorGeneric'));
setLoading(false);
}
};
rejectBooking();
}, [token, router]);
}, [token, router, t]);
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">
Traitement en cours...
{t('reject.loadingTitle')}
</h1>
<p className="text-gray-600">
Nous traitons votre refus.
{t('reject.loadingMessage')}
</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">Erreur</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</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"
>
Retour à l'accueil
{t('common.backHome')}
</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">
Merci de votre réponse
{t('reject.thanksTitle')}
</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
<p className="text-orange-800 font-medium text-lg mb-2">
Votre refus a bien é pris en compte
{t('reject.successHeadline')}
</p>
<p className="text-orange-700 text-sm">
Nous avons bien enregistré votre décision concernant cette demande de transport.
{t('reject.successBody')}
</p>
</div>
<p className="text-gray-500 text-sm mb-4">
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
{t('common.redirecting', { countdown })}
</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"
>
Retour à l'accueil
{t('common.backHome')}
</button>
</div>
</div>

View File

@ -1,6 +1,7 @@
'use client';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion';
import {
Shield,
@ -15,86 +16,41 @@ import {
Edit,
Eye,
Mail,
type LucideIcon,
} from 'lucide-react';
import Link from 'next/link';
import { Link } from '@/i18n/navigation';
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: {
@ -141,30 +97,29 @@ 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">Conformité européenne</span>
<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">
Conformité
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
RGPD
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
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.
{t('intro')}
</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">Conforme RGPD</span>
<span className="text-white text-sm">{t('badges.compliant')}</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">DPO désigné</span>
<span className="text-white text-sm">{t('badges.dpo')}</span>
</div>
</div>
</motion.div>
@ -191,10 +146,10 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
Vos droits RGPD
{t('rightsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Le RGPD vous confère des droits renforcés sur vos données personnelles
{t('rightsSubtitle')}
</p>
</motion.div>
@ -204,11 +159,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, index) => {
{RIGHTS.map((right) => {
const IconComponent = right.icon;
return (
<motion.div
key={index}
key={right.key}
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"
@ -216,8 +171,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">{right.title}</h3>
<p className="text-gray-600">{right.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3>
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
</motion.div>
);
})}
@ -231,20 +186,20 @@ export default function CompliancePage() {
className="mt-12 text-center"
>
<p className="text-gray-600 mb-4">
Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
{t('rightsCta.text')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<Link
href="/login"
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
>
Accéder à mon compte
{t('rightsCta.login')}
</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"
>
Contacter le DPO
{t('rightsCta.dpo')}
</a>
</div>
</motion.div>
@ -262,19 +217,19 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
Nos principes de protection des données
{t('principlesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des principes fondamentaux qui guident notre traitement des données
{t('principlesSubtitle')}
</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={index}
key={principle.key}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
@ -284,8 +239,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">{principle.title}</h3>
<p className="text-gray-600 text-sm">{principle.description}</p>
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
</motion.div>
);
})}
@ -304,29 +259,29 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
Mesures de protection
{t('measuresTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des mesures techniques et organisationnelles pour assurer la sécurité de vos données
{t('measuresSubtitle')}
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{measures.map((measure, index) => (
{MEASURES.map((key, index) => (
<motion.div
key={index}
key={key}
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">{measure.category}</h3>
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
<ul className="space-y-4">
{measure.items.map((item, i) => (
<li key={i} className="flex items-center space-x-3 text-white/80">
{MEASURE_ITEMS.map((itemKey) => (
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
<span>{item}</span>
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
</li>
))}
</ul>
@ -352,33 +307,18 @@ export default function CompliancePage() {
</div>
<div>
<h3 className="text-2xl font-bold text-brand-navy mb-4">
Registre des traitements
{t('register.title')}
</h3>
<p className="text-gray-600 mb-6">
Conformément à l'article 30 du RGPD, nous tenons un registre des activités de traitement
des données personnelles. Ce registre documente :
{t('register.body')}
</p>
<ul className="space-y-3 text-gray-600">
<li className="flex items-center space-x-3">
{REGISTER_ITEMS.map((itemKey) => (
<li key={itemKey} className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>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>
<span>{t(`register.${itemKey}` as any)}</span>
</li>
))}
</ul>
</div>
</div>
@ -398,11 +338,10 @@ 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">
Contacter notre DPO
{t('dpo.title')}
</h3>
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
Notre Délégué à la Protection des Données est à votre disposition pour toute question
relative au traitement de vos données personnelles ou à l'exercice de vos droits.
{t('dpo.body')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<a
@ -416,7 +355,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"
>
Politique de confidentialité
{t('dpo.privacyLink')}
</Link>
</div>
</motion.div>

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion';
import {
Mail,
@ -17,11 +18,26 @@ 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: '',
@ -64,7 +80,7 @@ export default function ContactPage() {
});
setIsSubmitted(true);
} catch (err: any) {
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
setError(err.message || t('form.genericError'));
} finally {
setIsSubmitting(false);
}
@ -79,59 +95,6 @@ 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: {
@ -178,20 +141,19 @@ 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">Nous contacter</span>
<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">
Une question ?
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
Nous sommes pour vous
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Notre équipe est disponible pour répondre à toutes vos questions sur notre plateforme,
nos services et nos tarifs. N'hésitez pas à nous contacter !
{t('intro')}
</p>
</motion.div>
</div>
@ -216,11 +178,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">
{contactMethods.map((method, index) => {
{METHODS.map((method) => {
const IconComponent = method.icon;
return (
<motion.div
key={index}
key={method.key}
variants={itemVariants}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
>
@ -229,9 +191,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">{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>
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3>
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p>
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p>
</motion.div>
);
})}
@ -249,9 +211,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">Envoyez-nous un message</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
<p className="text-gray-600 mb-8">
Remplissez le formulaire ci-dessous et nous vous répondrons dans les plus brefs délais.
{t('form.description')}
</p>
{isSubmitted ? (
@ -263,9 +225,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">Message envoyé !</h3>
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3>
<p className="text-green-700 mb-6">
Merci pour votre message. Notre équipe vous répondra dans les 24 heures.
{t('form.successBody')}
</p>
<button
onClick={() => {
@ -282,7 +244,7 @@ export default function ContactPage() {
}}
className="text-brand-turquoise font-medium hover:underline"
>
Envoyer un autre message
{t('form.sendAnother')}
</button>
</motion.div>
) : (
@ -290,7 +252,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">
Prénom *
{t('form.firstName')} *
</label>
<input
type="text"
@ -300,12 +262,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="Jean"
placeholder={t('form.firstNamePlaceholder')}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
Nom *
{t('form.lastName')} *
</label>
<input
type="text"
@ -315,7 +277,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="Dupont"
placeholder={t('form.lastNamePlaceholder')}
/>
</div>
</div>
@ -323,7 +285,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">
Email *
{t('form.email')} *
</label>
<input
type="email"
@ -333,12 +295,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="jean.dupont@exemple.com"
placeholder={t('form.emailPlaceholder')}
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
Téléphone
{t('form.phone')}
</label>
<input
type="tel"
@ -347,14 +309,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="+33 6 12 34 56 78"
placeholder={t('form.phonePlaceholder')}
/>
</div>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
Entreprise
{t('form.company')}
</label>
<input
type="text"
@ -363,13 +325,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="Votre entreprise"
placeholder={t('form.companyPlaceholder')}
/>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
Sujet *
{t('form.subject')} *
</label>
<select
id="subject"
@ -379,9 +341,10 @@ 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"
>
{subjects.map((subject) => (
<option key={subject.value} value={subject.value}>
{subject.label}
<option value="">{t('subjects.placeholder')}</option>
{SUBJECTS.map((key) => (
<option key={key} value={key}>
{t(`subjects.${key}`)}
</option>
))}
</select>
@ -389,7 +352,7 @@ export default function ContactPage() {
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
Message *
{t('form.message')} *
</label>
<textarea
id="message"
@ -399,7 +362,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="Comment pouvons-nous vous aider ?"
placeholder={t('form.messagePlaceholder')}
/>
</div>
@ -417,12 +380,12 @@ export default function ContactPage() {
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Envoi en cours...</span>
<span>{t('form.submitting')}</span>
</>
) : (
<>
<Send className="w-5 h-5" />
<span>Envoyer le message</span>
<span>{t('form.submit')}</span>
</>
)}
</button>
@ -436,87 +399,72 @@ 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">Notre bureau</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
<p className="text-gray-600 mb-8">
Retrouvez-nous à Paris ou contactez-nous par email.
{t('office.subtitle')}
</p>
<div className="space-y-6">
{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="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
<div className="flex items-start space-x-4">
<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 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>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-xl font-bold text-brand-navy">{office.city}</h3>
{office.isHQ && (
<h3 className="text-xl font-bold text-brand-navy">{t('office.city')}</h3>
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-turquoise text-xs font-medium rounded-full">
Siège social
{t('office.hqBadge')}
</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>{office.address}</span>
<span>{t('office.address')}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-gray-400 ml-6">{office.postalCode}</span>
<span className="text-gray-400 ml-6">{t('office.postalCode')}</span>
</div>
<div className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-400" />
<a href={`tel:${office.phone.replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{office.phone}
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{t('office.phone')}
</a>
</div>
<div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-400" />
<a href={`mailto:${office.email}`} className="hover:text-brand-turquoise">
{office.email}
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise">
{t('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">Horaires d'ouverture</h3>
<h3 className="text-lg font-bold text-brand-navy">{t('hours.title')}</h3>
</div>
<div className="space-y-2 text-gray-600">
<div className="flex justify-between">
<span>Lundi - Vendredi</span>
<span className="font-medium">9h00 - 18h00</span>
<span>{t('hours.weekdays')}</span>
<span className="font-medium">{t('hours.weekdaysHours')}</span>
</div>
<div className="flex justify-between">
<span>Samedi</span>
<span className="font-medium">10h00 - 14h00</span>
<span>{t('hours.saturday')}</span>
<span className="font-medium">{t('hours.saturdayHours')}</span>
</div>
<div className="flex justify-between">
<span>Dimanche</span>
<span className="font-medium text-gray-400">Fermé</span>
<span>{t('hours.sunday')}</span>
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-500">
* Support technique disponible 24/7 pour les clients Enterprise
{t('hours.supportNote')}
</p>
</div>
</motion.div>
@ -524,7 +472,7 @@ export default function ContactPage() {
</div>
</section>
{/* Section 1 : Ce qui se passe après l'envoi */}
{/* Section 1 : After submit */}
<section ref={afterSubmitRef} className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
@ -533,7 +481,6 @@ 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" />
@ -545,15 +492,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">
Après votre envoi
{t('afterSubmit.badge')}
</span>
</div>
<h2 className="text-2xl lg:text-3xl font-bold text-white mb-8">
Que se passe-t-il après l'envoi de votre message ?
{t('afterSubmit.title')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Notre engagement */}
{/* Commitment */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
@ -564,19 +511,17 @@ 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">Notre engagement</h3>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3>
</div>
<p className="text-white/80 leading-relaxed">
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{' '}
{t('afterSubmit.commitmentBody1')}
<span className="text-brand-turquoise font-semibold">
sous 48 heures ouvrées.
{t('afterSubmit.commitmentHighlight')}
</span>
</p>
</motion.div>
{/* Sécurité */}
{/* Security */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
@ -587,14 +532,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">Sécurité</h3>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3>
</div>
<p className="text-white/80 leading-relaxed">
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.
{t('afterSubmit.securityBody1')}
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
{t('afterSubmit.privacyLink')}
</Link>
{t('afterSubmit.securityBody2')}
</p>
</motion.div>
</div>
@ -603,7 +548,7 @@ export default function ContactPage() {
</div>
</section>
{/* Section 2 : Accès Rapide */}
{/* Section 2: Quick access */}
<section ref={quickAccessRef} className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
@ -613,15 +558,15 @@ export default function ContactPage() {
>
<div className="text-center mb-10">
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
Accès rapide
{t('quickAccess.badge')}
</span>
<h2 className="text-2xl lg:text-3xl font-bold text-brand-navy mt-2">
Besoin d'une réponse immédiate ?
{t('quickAccess.title')}
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Tarification instantanée */}
{/* Instant pricing */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
@ -632,22 +577,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">Tarification instantanée</h3>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
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.
{t('quickAccess.pricingBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span>
{t('quickAccess.pricingBody2')}
</p>
<a
<Link
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>Accéder au Dashboard</span>
<span>{t('quickAccess.pricingCta')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</Link>
</motion.div>
{/* Wiki Maritime */}
{/* Wiki */}
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
@ -658,19 +603,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">Aide rapide</h3>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
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.
{t('quickAccess.wikiBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span>
{t('quickAccess.wikiBody2')}
</p>
<a
<Link
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>Consulter le Wiki</span>
<span>{t('quickAccess.wikiCta')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</Link>
</motion.div>
</div>
</motion.div>

View File

@ -2,63 +2,77 @@
import { useRef } from 'react';
import { motion, useInView } from 'framer-motion';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
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: {
@ -105,29 +119,25 @@ 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">Transparence</span>
<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">
Politique de
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
Cookies
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
Découvrez comment nous utilisons les cookies pour améliorer votre expérience
sur Xpeditis et comment vous pouvez gérer vos préférences.
{t('intro')}
</p>
<p className="text-white/60 text-sm">
Dernière mise à jour : Janvier 2025
</p>
<p className="text-white/60 text-sm">{tCommon('lastUpdated')}</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
@ -148,16 +158,9 @@ 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">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>
<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>
</motion.div>
</div>
</section>
@ -171,10 +174,8 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }}
className="text-center mb-12"
>
<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>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2>
<p className="text-gray-600">{t('typesSubtitle')}</p>
</motion.div>
<motion.div
@ -183,11 +184,11 @@ export default function CookiesPage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-8"
>
{cookieTypes.map((type, index) => {
{COOKIE_TYPES.map((type) => {
const IconComponent = type.icon;
return (
<motion.div
key={index}
key={type.key}
variants={itemVariants}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
>
@ -197,18 +198,22 @@ export default function CookiesPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
<p className="text-gray-500 text-sm">{type.description}</p>
<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>
</div>
</div>
{type.required ? (
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
Requis
{t('required')}
</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">Optionnel</span>
<span className="text-sm text-gray-500">{t('optional')}</span>
</div>
)}
</div>
@ -217,17 +222,27 @@ 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">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>
<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>
</tr>
</thead>
<tbody>
{type.cookies.map((cookie, i) => (
<tr key={i} className="border-b border-gray-100 last:border-0">
{type.cookies.map((cookie) => (
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
<td className="py-3 px-4 text-gray-600">{cookie.purpose}</td>
<td className="py-3 px-4 text-gray-500">{cookie.duration}</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>
</tr>
))}
</tbody>
@ -245,19 +260,15 @@ 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">Comment gérer vos cookies ?</h3>
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('manageTitle')}</h3>
<div className="space-y-4 text-gray-600">
<p>
Vous pouvez à tout moment modifier vos préférences en matière de cookies :
</p>
<p>{t('manageIntro')}</p>
<ul className="list-disc pl-6 space-y-2">
<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>
<li>{t('manageBullet1')}</li>
<li>{t('manageBullet2')}</li>
<li>{t('manageBullet3')}</li>
</ul>
<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>
<p className="text-sm text-gray-500 mt-4">{t('manageNote')}</p>
</div>
</motion.div>
@ -269,11 +280,8 @@ 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">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>
<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: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,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
interface Booking {
@ -26,6 +27,10 @@ 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);
@ -43,26 +48,26 @@ export default function AdminBookingsPage() {
}, []);
const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
if (!window.confirm(t('confirmDelete'))) return;
setDeletingId(bookingId);
try {
await deleteAdminBooking(bookingId);
setBookings(prev => prev.filter(b => b.id !== bookingId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
setError(err.message || t('deleteError'));
} finally {
setDeletingId(null);
}
};
const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
if (!window.confirm(t('confirmValidate'))) return;
setValidatingId(bookingId);
try {
await validateBankTransfer(bookingId);
await fetchBookings();
} catch (err: any) {
setError(err.message || 'Erreur lors de la validation du virement');
setError(err.message || t('validateError'));
} finally {
setValidatingId(null);
}
@ -75,7 +80,7 @@ export default function AdminBookingsPage() {
setBookings(response.bookings || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Impossible de charger les réservations');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
@ -94,15 +99,12 @@ export default function AdminBookingsPage() {
};
const getStatusLabel = (status: string) => {
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 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 getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
@ -130,7 +132,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">Chargement des réservations...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -140,38 +142,38 @@ export default function AdminBookingsPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-1 text-sm text-gray-500">
Toutes les réservations de la plateforme
{t('subtitle')}
</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">Total</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.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">Virements à valider</div>
<div className="text-xs text-amber-700 uppercase tracking-wide">{t('stats.pendingBankTransfer')}</div>
<div className="text-2xl font-bold text-amber-700 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.pendingCarrier')}</div>
<div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</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">Acceptées</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.accepted')}</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">Rejetées</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.rejected')}</div>
<div className="text-2xl font-bold text-red-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div>
@ -182,29 +184,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">Recherche</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search.label')}</label>
<input
type="text"
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
placeholder={t('search.placeholder')}
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">Statut</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('filter.label')}</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
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">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>
<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>
</select>
</div>
</div>
@ -224,25 +226,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">
N° Booking
{t('table.bookingNumber')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
{t('table.route')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cargo
{t('table.cargo')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
{t('table.carrier')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
{t('table.status')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
{t('table.date')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('table.actions')}
</th>
</tr>
</thead>
@ -250,7 +252,7 @@ export default function AdminBookingsPage() {
{filteredBookings.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
Aucune réservation trouvée
{t('table.empty')}
</td>
</tr>
) : (
@ -276,11 +278,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} pal.</span>
<span className="ml-1 text-gray-500">· {booking.palletCount} {t('table.pallets')}</span>
)}
</div>
<div className="text-xs text-gray-500 space-x-2">
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
</div>
</td>
@ -299,7 +301,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('fr-FR')}
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(dateLocale)}
</td>
{/* Actions */}
@ -357,7 +359,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">Voir les détails</span>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
</button>
{(() => {
const booking = bookings.find(b => b.id === openMenuId);
@ -375,7 +377,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">Valider virement</span>
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span>
</button>
) : null;
})()}
@ -392,7 +394,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">Supprimer</span>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>
</div>
</div>
@ -404,7 +406,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">Détails de la réservation</h2>
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="text-gray-400 hover:text-gray-600"
@ -418,13 +420,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">N° Booking</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.bookingNumber')}</label>
<div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Statut</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
{getStatusLabel(selectedBooking.status)}
</span>
@ -432,45 +434,45 @@ export default function AdminBookingsPage() {
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.routeSection')}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origine</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.cargoSection')}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Type conteneur</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
</div>
{selectedBooking.palletCount != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Palettes</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
</div>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Poids</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Volume</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
</div>
)}
@ -479,18 +481,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">Prix</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.priceSection')}</h3>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && (
<div>
<label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} </div>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString(dateLocale)} </div>
</div>
)}
{selectedBooking.priceUSD != null && (
<div>
<label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString(dateLocale)} $</div>
</div>
)}
</div>
@ -498,18 +500,18 @@ export default function AdminBookingsPage() {
)}
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.datesSection')}</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Créée le</label>
<label className="block text-gray-500">{t('modal.createdAt')}</label>
<div className="mt-1 text-gray-900">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)}
</div>
</div>
{selectedBooking.updatedAt && (
<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>
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div>
</div>
)}
</div>
@ -526,7 +528,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 ? 'Validation...' : '✓ Valider le virement'}
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')}
</button>
</div>
)}
@ -537,7 +539,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"
>
Fermer
{t('modal.close')}
</button>
</div>
</div>

View File

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

View File

@ -1,6 +1,7 @@
'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';
@ -46,6 +47,10 @@ 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);
@ -67,19 +72,6 @@ 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() || '';
@ -106,7 +98,6 @@ export default function AdminDocumentsPage() {
const allBookings = response.bookings || [];
setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = [];
const userIds = new Set<string>();
@ -114,28 +105,15 @@ 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);
}
@ -155,10 +133,8 @@ 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(
@ -168,18 +144,13 @@ 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) + '...';
});
@ -188,24 +159,22 @@ export default function AdminDocumentsPage() {
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load documents');
setError(err.message || t('loadError'));
} 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())) ||
@ -221,13 +190,11 @@ 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]);
@ -270,13 +237,13 @@ export default function AdminDocumentsPage() {
};
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm('Supprimer définitivement ce document ?')) return;
if (!window.confirm(t('confirmDelete'))) return;
setDeletingId(documentId);
try {
await deleteAdminDocument(bookingId, documentId);
setDocuments(prev => prev.filter(d => d.id !== documentId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
setError(err.message || t('deleteError'));
} finally {
setDeletingId(null);
}
@ -284,7 +251,6 @@ 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;
@ -294,7 +260,6 @@ 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, {
@ -321,7 +286,8 @@ export default function AdminDocumentsPage() {
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
alert(`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
const message = error instanceof Error ? error.message : t('unknownError');
alert(t('downloadError', { message }));
}
};
@ -330,7 +296,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">Chargement des documents...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -339,24 +305,24 @@ export default function AdminDocumentsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Gestion des Documents"
description="Liste de tous les documents des devis CSV"
title={t('title')}
description={t('subtitle')}
/>
{/* 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">Total Documents</div>
<div className="text-sm text-gray-500">{t('stats.totalDocs')}</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">Devis avec Documents</div>
<div className="text-sm text-gray-500">{t('stats.bookingsWithDocs')}</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">Documents Filtrés</div>
<div className="text-sm text-gray-500">{t('stats.filtered')}</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
@ -366,11 +332,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">
Recherche
{t('filters.search')}
</label>
<input
type="text"
placeholder="Nom, type, route..."
placeholder={t('filters.searchPlaceholder')}
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"
@ -378,11 +344,11 @@ export default function AdminDocumentsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Numéro de Devis
{t('filters.quoteNumber')}
</label>
<input
type="text"
placeholder="Ex: #F2CAD5E1"
placeholder={t('filters.quoteNumberPlaceholder')}
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"
@ -390,14 +356,14 @@ export default function AdminDocumentsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Utilisateur
{t('filters.user')}
</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">Tous les utilisateurs</option>
<option value="all">{t('filters.allUsers')}</option>
{uniqueUsers.map(user => (
<option key={user.id} value={user.id}>
{user.name}
@ -421,25 +387,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">
Nom du Document
{t('table.name')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
{t('table.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Numéro de Devis
{t('table.quoteNumber')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
{t('table.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
{t('table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisateur
{t('table.user')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('table.actions')}
</th>
</tr>
</thead>
@ -447,7 +413,7 @@ export default function AdminDocumentsPage() {
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
Aucun document trouvé
{t('table.empty')}
</td>
</tr>
) : (
@ -515,27 +481,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"
>
Précédent
{t('pagination.previous')}
</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"
>
Suivant
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
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
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">Par page:</label>
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<select
value={itemsPerPage}
onChange={(e) => {
@ -557,7 +523,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">Précédent</span>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
@ -596,7 +562,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">Suivant</span>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
@ -636,7 +602,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élécharger</span>
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span>
</button>
<button
onClick={() => {
@ -652,7 +618,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">Supprimer</span>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>
</>
);

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import {
Download,
RefreshCw,
@ -104,6 +105,10 @@ 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);
@ -191,8 +196,8 @@ export default function AdminLogsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Logs système"
description="Visualisation et export des logs applicatifs en temps réel"
title={t('title')}
description={t('subtitle')}
actions={
<div className="flex items-center gap-2">
<button
@ -201,7 +206,7 @@ export default function AdminLogsPage() {
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Actualiser</span>
<span className="hidden sm:inline">{t('refresh')}</span>
</button>
<div className="relative group">
<button
@ -209,20 +214,20 @@ export default function AdminLogsPage() {
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">{exportLoading ? 'Export...' : 'Exporter'}</span>
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span>
</button>
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
<button
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger CSV
{t('downloadCsv')}
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger JSON
{t('downloadJson')}
</button>
</div>
</div>
@ -233,25 +238,25 @@ export default function AdminLogsPage() {
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Total logs"
label={t('stats.total')}
value={total}
icon={Activity}
color="bg-blue-100 text-blue-600"
/>
<StatCard
label="Erreurs"
label={t('stats.errors')}
value={countByLevel('error') + countByLevel('fatal')}
icon={AlertTriangle}
color="bg-red-100 text-red-600"
/>
<StatCard
label="Warnings"
label={t('stats.warnings')}
value={countByLevel('warn')}
icon={AlertTriangle}
color="bg-yellow-100 text-yellow-600"
/>
<StatCard
label="Info"
label={t('stats.info')}
value={countByLevel('info')}
icon={Info}
color="bg-green-100 text-green-600"
@ -262,18 +267,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">Filtres</h2>
<h2 className="text-sm font-semibold text-gray-700">{t('filters.title')}</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">Service</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.service')}</label>
<select
value={filters.service}
onChange={e => setFilter('service', e.target.value)}
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">Tous</option>
<option value="all">{t('filters.all')}</option>
{services.map(s => (
<option key={s} value={s}>
{s}
@ -284,13 +289,13 @@ export default function AdminLogsPage() {
{/* Level */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Niveau</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.level')}</label>
<select
value={filters.level}
onChange={e => setFilter('level', e.target.value)}
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">Tous</option>
<option value="all">{t('filters.all')}</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
<option value="warn">Warn</option>
@ -301,10 +306,10 @@ export default function AdminLogsPage() {
{/* Search */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Recherche</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.search')}</label>
<input
type="text"
placeholder="Texte libre..."
placeholder={t('filters.searchPlaceholder')}
value={filters.search}
onChange={e => setFilter('search', e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
@ -314,7 +319,7 @@ export default function AdminLogsPage() {
{/* Start */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Début</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.start')}</label>
<input
type="datetime-local"
value={filters.startDate}
@ -325,7 +330,7 @@ export default function AdminLogsPage() {
{/* End */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Fin</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.end')}</label>
<input
type="datetime-local"
value={filters.endDate}
@ -336,7 +341,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">Limite</label>
<label className="block text-xs font-medium text-gray-500">{t('filters.limit')}</label>
<div className="flex gap-2">
<select
value={filters.limit}
@ -353,7 +358,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"
>
Filtrer
{t('filters.apply')}
</button>
</div>
</div>
@ -365,10 +370,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">
Impossible de contacter le log-exporter : <strong>{error}</strong>
{t('errorBanner')} <strong>{error}</strong>
<br />
<span className="text-xs text-red-500">
Vérifiez que le backend et le log-exporter sont démarrés.
{t('errorHint')}
</span>
</span>
</div>
@ -380,12 +385,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 ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`}
{loading ? t('loading') : t('entries', { count: total })}
</span>
</div>
{!loading && logs.length > 0 && (
<span className="text-xs text-gray-400">
Cliquer sur une ligne pour les détails
{t('clickHint')}
</span>
)}
</div>
@ -397,7 +402,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">Aucun log trouvé pour ces filtres</p>
<p className="text-sm">{t('empty')}</p>
</div>
) : (
<div className="overflow-x-auto">
@ -405,22 +410,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">
Timestamp
{t('table.timestamp')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Service
{t('table.service')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Niveau
{t('table.level')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Contexte
{t('table.context')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Message
{t('table.message')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Req / Status
{t('table.req')}
</th>
</tr>
</thead>
@ -433,7 +438,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('fr-FR', {
{new Date(log.timestamp).toLocaleString(dateLocale, {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
@ -490,25 +495,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">Timestamp</span>
<span className="font-semibold text-gray-600">{t('detail.timestamp')}</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
</div>
{log.reqId && (
<div>
<span className="font-semibold text-gray-600">Request ID</span>
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
</div>
)}
{log.response_time_ms && (
<div>
<span className="font-semibold text-gray-600">Durée</span>
<span className="font-semibold text-gray-600">{t('detail.duration')}</span>
<p className="font-mono text-gray-800 mt-0.5">
{log.response_time_ms} ms
</p>
</div>
)}
<div className="col-span-2 md:col-span-4">
<span className="font-semibold text-gray-600">Message complet</span>
<span className="font-semibold text-gray-600">{t('detail.fullMessage')}</span>
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
{log.error
? `[ERROR] ${log.error}\n\n${log.message}`

View File

@ -1,6 +1,7 @@
'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';
@ -30,6 +31,8 @@ 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);
@ -86,7 +89,7 @@ export default function AdminOrganizationsPage() {
setOrganizations(response.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load organizations');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
@ -95,10 +98,9 @@ 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, // OrganizationType
type: formData.type as any,
address_street: formData.address.street,
address_city: formData.address.city,
address_postal_code: formData.address.postalCode,
@ -112,7 +114,7 @@ export default function AdminOrganizationsPage() {
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to create organization');
alert(err.message || t('createError'));
}
};
@ -127,7 +129,7 @@ export default function AdminOrganizationsPage() {
setSelectedOrg(null);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to update organization');
alert(err.message || t('updateError'));
}
};
@ -157,41 +159,41 @@ export default function AdminOrganizationsPage() {
setVerifyingId(orgId);
const result = await verifySiret(orgId);
if (result.verified) {
alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`);
alert(t('siretVerified', { companyName: result.companyName || 'N/A', address: result.address || 'N/A' }));
await fetchOrganizations();
} else {
alert(result.message || 'SIRET invalide ou introuvable.');
alert(result.message || t('siretInvalid'));
}
} catch (err: any) {
alert(err.message || 'Erreur lors de la verification du SIRET');
alert(err.message || t('siretError'));
} finally {
setVerifyingId(null);
}
};
const handleApproveSiret = async (orgId: string) => {
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
if (!confirm(t('confirmApprove'))) return;
try {
setVerifyingId(orgId);
const result = await approveSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || 'Erreur lors de l\'approbation');
alert(err.message || t('siretApproveError'));
} finally {
setVerifyingId(null);
}
};
const handleRejectSiret = async (orgId: string) => {
if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return;
if (!confirm(t('confirmReject'))) return;
try {
setVerifyingId(orgId);
const result = await rejectSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || 'Erreur lors du refus');
alert(err.message || t('siretRejectError'));
} finally {
setVerifyingId(null);
}
@ -214,12 +216,20 @@ 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">Loading organizations...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -228,14 +238,14 @@ export default function AdminOrganizationsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Organization Management"
description="Manage all organizations in the system"
title={t('title')}
description={t('subtitle')}
actions={
<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"
>
+ Create Organization
{t('create')}
</button>
}
/>
@ -259,53 +269,53 @@ export default function AdminOrganizationsPage() {
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
{org.type.replace('_', ' ')}
{getTypeLabel(org.type)}
</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 ? 'Active' : 'Inactive'}
{org.isActive ? t('active') : t('inactive')}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
{org.scac && (
<div>
<span className="font-medium">SCAC:</span> {org.scac}
<span className="font-medium">{t('scac')}:</span> {org.scac}
</div>
)}
{org.siren && (
<div>
<span className="font-medium">SIREN:</span> {org.siren}
<span className="font-medium">{t('siren')}:</span> {org.siren}
</div>
)}
<div className="flex items-center gap-2">
<span className="font-medium">SIRET:</span>
<span className="font-medium">{t('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">
Verifie
{t('verified')}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Non verifie
{t('notVerified')}
</span>
)}
</>
) : (
<span className="text-gray-400">Non renseigne</span>
<span className="text-gray-400">{t('notProvided')}</span>
)}
</div>
{org.contact_email && (
<div>
<span className="font-medium">Email:</span> {org.contact_email}
<span className="font-medium">{t('email')}:</span> {org.contact_email}
</div>
)}
<div>
<span className="font-medium">Location:</span> {org.address.city}, {org.address.country}
<span className="font-medium">{t('location')}:</span> {org.address.city}, {org.address.country}
</div>
</div>
@ -315,7 +325,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"
>
Edit
{t('edit')}
</button>
{org.siret && !org.siretVerified && (
<button
@ -323,7 +333,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 ? '...' : 'Verifier API'}
{verifyingId === org.id ? t('verifying') : t('verifyApi')}
</button>
)}
</div>
@ -335,7 +345,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"
>
Approuver SIRET
{t('approveSiret')}
</button>
) : (
<button
@ -343,7 +353,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"
>
Rejeter SIRET
{t('rejectSiret')}
</button>
)}
</div>
@ -358,12 +368,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 ? 'Create New Organization' : 'Edit Organization'}
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
</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">Organization Name *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label>
<input
type="text"
required
@ -374,21 +384,21 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Type *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.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">Freight Forwarder</option>
<option value="CARRIER">Carrier</option>
<option value="SHIPPER">Shipper</option>
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
<option value="CARRIER">{t('types.CARRIER')}</option>
<option value="SHIPPER">{t('types.SHIPPER')}</option>
</select>
</div>
{formData.type === 'CARRIER' && (
<div>
<label className="block text-sm font-medium text-gray-700">SCAC Code *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.scacLabel')}</label>
<input
type="text"
required={formData.type === 'CARRIER'}
@ -401,7 +411,7 @@ export default function AdminOrganizationsPage() {
)}
<div>
<label className="block text-sm font-medium text-gray-700">SIREN</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.sirenLabel')}</label>
<input
type="text"
maxLength={9}
@ -412,19 +422,19 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.siretLabel')}</label>
<input
type="text"
maxLength={14}
value={formData.siret}
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder="12345678901234"
placeholder={t('modal.siretPlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">EORI</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.eoriLabel')}</label>
<input
type="text"
value={formData.eori}
@ -434,7 +444,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Phone</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactPhone')}</label>
<input
type="tel"
value={formData.contact_phone}
@ -444,7 +454,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Email</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactEmail')}</label>
<input
type="email"
value={formData.contact_email}
@ -454,7 +464,7 @@ export default function AdminOrganizationsPage() {
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Street Address *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label>
<input
type="text"
required
@ -468,7 +478,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.city')}</label>
<input
type="text"
required
@ -482,7 +492,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.postalCode')}</label>
<input
type="text"
required
@ -496,7 +506,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">State/Region</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.state')}</label>
<input
type="text"
value={formData.address.state}
@ -509,7 +519,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Country *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.country')}</label>
<input
type="text"
required
@ -524,7 +534,7 @@ export default function AdminOrganizationsPage() {
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Logo URL</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label>
<input
type="url"
value={formData.logoUrl}
@ -545,13 +555,13 @@ export default function AdminOrganizationsPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{showCreateModal ? 'Create' : 'Update'}
{showCreateModal ? t('modal.create') : t('modal.update')}
</button>
</div>
</form>

View File

@ -1,6 +1,7 @@
'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';
@ -25,6 +26,8 @@ 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);
@ -51,7 +54,6 @@ export default function AdminUsersPage() {
password: '',
});
// Fetch users and organizations
useEffect(() => {
fetchData();
}, []);
@ -68,7 +70,7 @@ export default function AdminUsersPage() {
setOrganizations(orgsResponse.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load data');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
@ -82,7 +84,7 @@ export default function AdminUsersPage() {
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to create user');
alert(err.message || t('createError'));
}
};
@ -102,7 +104,7 @@ export default function AdminUsersPage() {
setSelectedUser(null);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to update user');
alert(err.message || t('updateError'));
}
};
@ -115,7 +117,7 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(false);
setSelectedUser(null);
} catch (err: any) {
alert(err.message || 'Failed to delete user');
alert(err.message || t('deleteError'));
}
};
@ -148,12 +150,20 @@ 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">Loading users...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -162,14 +172,14 @@ export default function AdminUsersPage() {
return (
<div className="space-y-6">
<PageHeader
title="User Management"
description="Manage all users in the system"
title={t('title')}
description={t('subtitle')}
actions={
<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"
>
+ Create User
{t('create')}
</button>
}
/>
@ -187,22 +197,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">
User
{t('table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
{t('table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
{t('table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization
{t('table.organization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
{t('table.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('table.actions')}
</th>
</tr>
</thead>
@ -223,7 +233,7 @@ export default function AdminUsersPage() {
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
{getRoleLabel(user.role)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -233,7 +243,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 ? 'Active' : 'Inactive'}
{user.isActive ? t('active') : t('inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
@ -241,13 +251,13 @@ export default function AdminUsersPage() {
onClick={() => openEditModal(user)}
className="text-blue-600 hover:text-blue-900"
>
Edit
{t('edit')}
</button>
<button
onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900"
>
Delete
{t('delete')}
</button>
</td>
</tr>
@ -260,10 +270,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">Create New User</h2>
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label>
<input
type="email"
required
@ -273,7 +283,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
<input
type="text"
required
@ -283,7 +293,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
<input
type="text"
required
@ -293,27 +303,27 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.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">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
<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>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Organization</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.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="">Select Organization</option>
<option value="">{t('modal.selectOrganization')}</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>
{org.name}
@ -323,7 +333,7 @@ export default function AdminUsersPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Password (leave empty for auto-generated)
{t('modal.password')}
</label>
<input
type="password"
@ -341,13 +351,13 @@ export default function AdminUsersPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Create
{t('modal.create')}
</button>
</div>
</form>
@ -359,10 +369,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">Edit User</h2>
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email (read-only)</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label>
<input
type="email"
disabled
@ -371,7 +381,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
<input
type="text"
required
@ -381,7 +391,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
<input
type="text"
required
@ -391,16 +401,16 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.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">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
<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>
</select>
</div>
<div className="flex justify-end space-x-2 pt-4">
@ -413,13 +423,13 @@ export default function AdminUsersPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Update
{t('modal.update')}
</button>
</div>
</form>
@ -431,10 +441,9 @@ 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">Confirm Delete</h2>
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
<p className="text-gray-700 mb-6">
Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>?
This action cannot be undone.
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })}
</p>
<div className="flex justify-end space-x-2">
<button
@ -444,13 +453,13 @@ export default function AdminUsersPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('deleteConfirm.cancel')}
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Delete
{t('deleteConfirm.confirm')}
</button>
</div>
</div>

View File

@ -1,17 +1,15 @@
/**
* Booking Detail Page
*
* Display full booking information
*/
'use client';
import { useQuery } from '@tanstack/react-query';
import { getBooking } from '@/lib/api';
import Link from 'next/link';
import { Link } from '@/i18n/navigation';
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;
@ -33,10 +31,18 @@ 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 {
// TODO: Implement PDF download functionality
alert('PDF download functionality is not yet implemented');
alert(t('pdfNotImplemented'));
console.log('Download PDF for booking:', bookingId);
} catch (error) {
console.error('Failed to download PDF:', error);
@ -54,12 +60,12 @@ export default function BookingDetailPage() {
if (!booking) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-semibold text-gray-900">Booking not found</h2>
<h2 className="text-2xl font-semibold text-gray-900">{t('notFound')}</h2>
<Link
href="/dashboard/bookings"
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
>
Back to bookings
{t('back')}
</Link>
</div>
);
@ -67,14 +73,13 @@ 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"
>
Back to bookings
{t('back')}
</Link>
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
@ -83,11 +88,11 @@ export default function BookingDetailPage() {
booking.status
)}`}
>
{booking.status}
{getStatusLabel(booking.status)}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
Created on {new Date(booking.createdAt).toLocaleDateString()}
{t('createdOn', { date: new Date(booking.createdAt).toLocaleDateString(dateLocale) })}
</p>
</div>
<div className="flex space-x-3">
@ -103,59 +108,56 @@ 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>
Download PDF
{t('downloadPdf')}
</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">Cargo Details</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('cargo.title')}</h2>
<dl className="grid grid-cols-1 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Description</dt>
<dt className="text-sm font-medium text-gray-500">{t('cargo.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">Special Instructions</dt>
<dt className="text-sm font-medium text-gray-500">{t('cargo.specialInstructions')}</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">
Containers ({booking.containers?.length || 0})
{t('containers.title', { count: 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">Type</p>
<p className="text-sm font-medium text-gray-500">{t('containers.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">Container Number</p>
<p className="text-sm font-medium text-gray-500">{t('containers.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">Seal Number</p>
<p className="text-sm font-medium text-gray-500">{t('containers.seal')}</p>
<p className="text-sm text-gray-900">{container.sealNumber}</p>
</div>
)}
{container.vgm && (
<div>
<p className="text-sm font-medium text-gray-500">VGM (kg)</p>
<p className="text-sm font-medium text-gray-500">{t('containers.vgm')}</p>
<p className="text-sm text-gray-900">{container.vgm}</p>
</div>
)}
@ -165,47 +167,46 @@ 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">Shipper</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('shipper.title')}</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.name')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.contact')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.email')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.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">Consignee</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('consignee.title')}</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.name')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.contact')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.email')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.phone')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
</div>
</dl>
@ -213,11 +214,9 @@ 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">Timeline</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('timeline.title')}</h2>
<div className="flow-root">
<ul className="-mb-8">
<li>
@ -244,9 +243,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">Booking Created</p>
<p className="text-sm text-gray-900 font-medium">{t('timeline.created')}</p>
<p className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleString()}
{new Date(booking.createdAt).toLocaleString(dateLocale)}
</p>
</div>
</div>
@ -257,18 +256,17 @@ 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">Information</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('info.title')}</h2>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">Booking ID</dt>
<dt className="text-sm font-medium text-gray-500">{t('info.bookingId')}</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dt className="text-sm font-medium text-gray-500">{t('info.lastUpdated')}</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(booking.updatedAt).toLocaleString()}
{new Date(booking.updatedAt).toLocaleString(dateLocale)}
</dd>
</div>
</dl>

View File

@ -1,23 +1,21 @@
/**
* 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 { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
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');
@ -32,29 +30,24 @@ 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, // Fetch all bookings for client-side filtering
limit: 1000,
}),
});
// 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();
@ -71,7 +64,7 @@ export default function BookingsListPage() {
case 'status':
return booking.status?.toLowerCase().includes(term);
case 'date':
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString('fr-FR');
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString(dateLocale);
return date.includes(term);
case 'quote':
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
@ -84,52 +77,42 @@ 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: 'Tous les statuts' },
{ value: 'PENDING', label: 'En attente' },
{ value: 'ACCEPTED', label: 'Accepté' },
{ value: 'REJECTED', label: 'Refusé' },
{ value: '', label: t('statusFilter.all') },
{ value: 'PENDING', label: t('status.pending') },
{ value: 'ACCEPTED', label: t('status.accepted') },
{ value: 'REJECTED', label: t('status.rejected') },
];
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 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 getPlaceholder = () => {
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 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);
};
const getStatusColor = (status: string) => {
@ -142,25 +125,24 @@ export default function BookingsListPage() {
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
const map: Record<string, string> = {
PENDING: t('status.pending'),
ACCEPTED: t('status.accepted'),
REJECTED: t('status.rejected'),
};
return labels[status] || status;
return map[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">Virement déclaré</p>
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
<p className="text-sm text-amber-700 mt-0.5">
Votre virement a é enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
{t('transferBanner.message')}
</p>
</div>
</div>
@ -168,30 +150,23 @@ export default function BookingsListPage() {
</div>
)}
<PageHeader
title="Réservations"
description="Gérez et suivez vos envois"
title={t('title')}
description={t('description')}
actions={
<>
<ExportButton
data={filteredBookings}
filename="reservations"
filename={t('exportFilename')}
columns={[
{ 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') : '' },
{ 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) : '' },
]}
/>
<Link
@ -199,18 +174,17 @@ export default function BookingsListPage() {
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"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Nouvelle Réservation</span>
<span className="hidden sm:inline">{t('new')}</span>
</Link>
</>
}
/>
{/* 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">
Type de recherche
{t('searchType.label')}
</label>
<select
id="searchType"
@ -230,7 +204,7 @@ export default function BookingsListPage() {
</div>
<div className="md:col-span-2">
<label htmlFor="search" className="sr-only">
Rechercher
{t('search')}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@ -263,7 +237,7 @@ export default function BookingsListPage() {
</div>
<div>
<label htmlFor="status" className="sr-only">
Statut
{t('statusFilter.label')}
</label>
<select
id="status"
@ -284,16 +258,14 @@ export default function BookingsListPage() {
</div>
</div>
{/* Bookings List */}
<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>
Chargement des réservations...
{t('loading')}
</div>
) : paginatedBookings && paginatedBookings.length > 0 ? (
<>
{/* Mobile cards */}
<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">
@ -314,64 +286,63 @@ export default function BookingsListPage() {
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wide">Palettes</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'
? `${booking.palletCount} pal.`
: `${booking.containers?.length || 0} cont.`}
? 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">Poids</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'
? `${booking.weightKG} kg`
: booking.totalWeight ? `${booking.totalWeight} kg` : 'N/A'}
? 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">Date</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('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit' })
? 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'
? `Réf: #${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: `Booking: ${booking.bookingNumber || '-'}`}
? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() })
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
</div>
))}
</div>
{/* Desktop table */}
<div className="hidden md:block 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">
Palettes/Colis
{t('columns.palletsPackages')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poids
{t('columns.weight')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
{t('columns.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
{t('columns.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
{t('columns.date')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis
{t('columns.quoteNumber')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
{t('columns.bookingNumber')}
</th>
</tr>
</thead>
@ -381,8 +352,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'
? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
: `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
? t('units.palletsCount', { count: booking.palletCount })
: t('units.containersCount', { count: booking.containers?.length || 0 })}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
@ -391,16 +362,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'
? `${booking.weightKG} kg`
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight
? `${booking.totalWeight} kg`
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv'
? `${booking.volumeCBM} CBM`
? t('units.cbm', { value: booking.volumeCBM })
: booking.totalVolume
? `${booking.totalVolume} CBM`
? t('units.cbm', { value: booking.totalVolume })
: ''}
</div>
</td>
@ -427,7 +398,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('fr-FR', {
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@ -448,7 +419,6 @@ 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">
@ -457,22 +427,25 @@ 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"
>
Précédent
{t('pagination.previous')}
</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"
>
Suivant
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
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' : ''}
{t.rich('pagination.showing', {
start: startIndex + 1,
end: Math.min(endIndex, totalBookings),
total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>,
})}
</p>
</div>
<div>
@ -482,16 +455,14 @@ 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">Précédent</span>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</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);
@ -524,7 +495,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">Suivant</span>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
@ -550,11 +521,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">Aucune réservation trouvée</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter
? 'Essayez d\'ajuster vos filtres'
: 'Commencez par créer votre première réservation'}
? t('empty.hasFilters')
: t('empty.noBookings')}
</p>
<div className="mt-6">
<Link
@ -562,7 +533,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" />
Nouvelle Réservation
{t('new')}
</Link>
</div>
</div>

View File

@ -1,6 +1,7 @@
'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';
@ -15,7 +16,6 @@ interface Document {
mimeType: string;
size: number;
uploadedAt?: Date;
// Legacy fields for compatibility
name?: string;
url?: string;
}
@ -30,6 +30,8 @@ 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);
@ -40,41 +42,27 @@ 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();
};
@ -82,23 +70,19 @@ 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('/');
@ -129,17 +113,16 @@ export default function UserDocumentsPage() {
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || 'Erreur lors du chargement des documents');
setError(err.message || t('error'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]);
// Filter documents
const filteredDocuments = documents.filter(doc => {
const matchesSearch =
searchTerm === '' ||
@ -157,13 +140,11 @@ 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]);
@ -206,18 +187,12 @@ export default function UserDocumentsPage() {
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[status] || status;
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
return t(`statuses.${key}` as any) || status;
};
const handleDownload = async (url: string, fileName: string) => {
try {
// Try direct download first
const link = document.createElement('a');
link.href = url;
link.download = fileName;
@ -227,18 +202,10 @@ 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');
@ -252,15 +219,12 @@ export default function UserDocumentsPage() {
console.error('Fetch download failed:', fetchError);
}
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
alert(
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
} catch (err) {
console.error('Error downloading file:', err);
alert(`${t('downloadError')}: ${err instanceof Error ? err.message : ''}`);
}
};
// Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
);
@ -272,14 +236,12 @@ 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('Veuillez sélectionner une réservation et au moins un fichier');
alert(t('addDocument.noBookingError'));
return;
}
@ -294,50 +256,34 @@ 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('Erreur lors de l\'ajout des documents');
}
if (!response.ok) throw new Error(t('addDocument.errorMessage'));
alert('Documents ajoutés avec succès!');
alert(t('addDocument.successMessage'));
handleCloseModal();
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'}`
);
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error uploading documents:', err);
alert(`${t('addDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
} 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);
@ -347,14 +293,12 @@ 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('Veuillez sélectionner un fichier de remplacement');
alert(t('replaceDocument.noFileError'));
return;
}
@ -366,28 +310,20 @@ 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 || 'Erreur lors du remplacement du document');
throw new Error(errorData.message || t('replaceDocument.errorMessage'));
}
alert('Document remplacé avec succès!');
alert(t('replaceDocument.successMessage'));
handleCloseReplaceModal();
fetchBookingsAndDocuments(); // Refresh the list
} catch (error) {
console.error('Error replacing document:', error);
alert(
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error replacing document:', err);
alert(`${t('replaceDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
} finally {
setReplacingFile(false);
}
@ -398,7 +334,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">Chargement des documents...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -407,29 +343,21 @@ export default function UserDocumentsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Mes Documents"
description="Gérez tous les documents de vos réservations"
title={t('title')}
description={t('description')}
actions={
<>
<ExportButton
data={filteredDocuments}
filename="documents"
columns={[
{ 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') : '' },
{ 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) : '' },
]}
/>
<button
@ -440,7 +368,7 @@ export default function UserDocumentsPage() {
<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">Ajouter un document</span>
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
</button>
</>
}
@ -449,17 +377,17 @@ export default function UserDocumentsPage() {
{/* 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">Total Documents</div>
<div className="text-sm text-gray-500">{t('stats.total')}</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">Réservations avec Documents</div>
<div className="text-sm text-gray-500">{t('stats.withDocuments')}</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">Documents Filtrés</div>
<div className="text-sm text-gray-500">{t('stats.filtered')}</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
@ -468,17 +396,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">Recherche</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
<input
type="text"
placeholder="Nom, type, route, transporteur..."
placeholder={t('filters.searchPlaceholder')}
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">Numéro de Devis</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.quoteNumber')}</label>
<input
type="text"
placeholder="Ex: #F2CAD5E1"
@ -488,16 +416,16 @@ export default function UserDocumentsPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</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">Tous les statuts</option>
<option value="PENDING">En attente</option>
<option value="ACCEPTED">Accepté</option>
<option value="REJECTED">Refusé</option>
<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>
</select>
</div>
</div>
@ -515,36 +443,20 @@ 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">
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>
<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>
</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
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
: 'Aucun document ne correspond aux filtres sélectionnés.'}
{documents.length === 0 ? t('empty.noDocuments') : t('empty.noMatch')}
</td>
</tr>
) : (
@ -555,9 +467,7 @@ 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>
@ -571,9 +481,7 @@ 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>
@ -585,20 +493,15 @@ 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="Actions"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
title={t('table.actions')}
>
<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"
@ -612,39 +515,19 @@ 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élécharger
{t('actions.download')}
</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>
Remplacer
{t('actions.replace')}
</button>
</div>
</div>
@ -666,29 +549,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"
>
Précédent
{t('pagination.previous')}
</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"
>
Suivant
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
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
{t('pagination.showing', {
from: startIndex + 1,
to: Math.min(endIndex, filteredDocuments.length),
total: filteredDocuments.length,
})}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">Par page:</label>
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<select
value={itemsPerPage}
onChange={e => {
@ -704,26 +587,18 @@ 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">Précédent</span>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
@ -756,13 +631,9 @@ 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">Suivant</span>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</nav>
@ -776,57 +647,35 @@ 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">
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseModal}
/>
{/* Modal panel */}
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseModal} />
<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">
Ajouter un document
</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('addDocument.modalTitle')}</h3>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sélectionner une réservation
</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</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="">-- Choisir une réservation --</option>
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
{bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? t('statuses.PENDING') : t('statuses.ACCEPTED')})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fichiers à ajouter
</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label>
<input
ref={fileInputRef}
type="file"
@ -834,9 +683,7 @@ 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">
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
</p>
<p className="mt-1 text-xs text-gray-500">{t('addDocument.acceptedFormats')}</p>
</div>
</div>
</div>
@ -851,37 +698,20 @@ 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>
Envoi en cours...
{t('addDocument.uploading')}
</>
) : (
'Ajouter'
)}
) : t('addDocument.add')}
</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"
>
Annuler
{t('addDocument.cancel')}
</button>
</div>
</div>
@ -893,60 +723,34 @@ 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">
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseReplaceModal}
/>
{/* Modal panel */}
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseReplaceModal} />
<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">
Remplacer le document
</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('replaceDocument.modalTitle')}</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">Document actuel:</p>
<p className="text-sm font-medium text-gray-900 mt-1">
{documentToReplace.fileName}
</p>
<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-xs text-gray-500 mt-1">
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nouveau fichier
</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</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">
Formats acceptés: PDF, Word, Excel, Images
</p>
<p className="mt-1 text-xs text-gray-500">{t('replaceDocument.acceptedFormats')}</p>
</div>
</div>
</div>
@ -961,37 +765,20 @@ 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>
Remplacement en cours...
{t('replaceDocument.replacing')}
</>
) : (
'Remplacer'
)}
) : t('replaceDocument.replace')}
</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"
>
Annuler
{t('replaceDocument.cancel')}
</button>
</div>
</div>

View File

@ -1,16 +1,11 @@
/**
* Dashboard Layout
*
* Layout with sidebar navigation for dashboard pages
*/
'use client';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Link, usePathname, useRouter } from '@/i18n/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 {
@ -36,6 +31,7 @@ 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(() => {
@ -57,16 +53,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: 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
{ 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 },
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
{ name: t('nav.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
] : []),
];
@ -79,7 +74,6 @@ 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"
@ -87,14 +81,12 @@ 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
@ -121,7 +113,6 @@ 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);
@ -144,7 +135,6 @@ 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 />
@ -152,7 +142,6 @@ 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">
@ -176,15 +165,13 @@ 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" />
Déconnexion
{t('logout')}
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<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">
<button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
@ -201,33 +188,30 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</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 || 'Tableau de bord'}
{navigation.find(item => isActive(item.href))?.name || t('topbar.defaultTitle')}
</h1>
</div>
<div className="flex items-center space-x-3 lg:space-x-4">
{/* Notifications */}
<LanguageSwitcher variant="light" />
<NotificationDropdown />
{/* User Initials */}
<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?.firstName?.[0]}{user?.lastName?.[0]}
</Link>
</div>
</div>
{/* Page content — extra bottom padding on mobile for bottom nav */}
<main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main>
</div>
{/* Mobile bottom navigation bar */}
<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: 'Accueil' },
{ href: '/dashboard/bookings', icon: Package, label: 'Réservations' },
{ href: '/dashboard/documents', icon: FileText, label: 'Documents' },
{ href: '/dashboard/track-trace', icon: Search, label: 'Suivi' },
{ href: '/dashboard/profile', icon: User, label: 'Profil' },
{ 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 (

View File

@ -1,14 +1,9 @@
/**
* 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,
@ -20,12 +15,14 @@ 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: () =>
@ -41,7 +38,6 @@ 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: () => {
@ -49,7 +45,6 @@ export default function NotificationsPage() {
},
});
// Mark all as read
const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead,
onSuccess: () => {
@ -57,7 +52,6 @@ export default function NotificationsPage() {
},
});
// Delete notification
const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification,
onSuccess: () => {
@ -70,7 +64,6 @@ export default function NotificationsPage() {
markAsReadMutation.mutate(notification.id);
}
// Navigate to actionUrl if available
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
@ -78,7 +71,7 @@ export default function NotificationsPage() {
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
if (confirm(t('deleteConfirm'))) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -93,6 +86,16 @@ 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> = {
@ -120,11 +123,11 @@ export default function NotificationsPage() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
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', {
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, {
month: 'long',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -135,7 +138,6 @@ 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">
@ -144,10 +146,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">Notifications</h1>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-600 mt-1">
{total} notification{total !== 1 ? 's' : ''} au total
{unreadCount > 0 && `${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
{t('totalLabel', { count: total })}
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
</p>
</div>
</div>
@ -158,20 +160,18 @@ 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>Tout marquer comme lu</span>
<span>{t('markAllRead')}</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">Filtrer :</span>
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</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'
}`}
>
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{t(`filter.${filter}` as any)}
{filter === 'unread' && unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
{unreadCount}
@ -198,24 +198,21 @@ 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">Chargement des notifications...</p>
<p className="text-gray-500">{t('loading')}</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">Aucune notification</h3>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
<p className="text-gray-500">
{selectedFilter === 'unread'
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
</p>
</div>
</div>
@ -230,12 +227,10 @@ 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">
@ -245,14 +240,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>NOUVEAU</span>
<span>{t('new')}</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="Supprimer la notification"
title={t('deleteTitle')}
>
<Trash2 className="w-5 h-5 text-red-600" />
</button>
@ -262,7 +257,6 @@ 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">
@ -296,13 +290,13 @@ export default function NotificationsPage() {
: 'bg-blue-100 text-blue-700'
}`}
>
{notification.priority.toUpperCase()}
{getPriorityLabel(notification.priority)}
</span>
)}
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
<span>Voir les détails</span>
<span>{t('viewDetails')}</span>
<svg
className="w-4 h-4"
fill="none"
@ -327,15 +321,16 @@ 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">
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
{t.rich('pagination.info', {
current: currentPage,
total: totalPages,
items: total,
b: (chunks) => <span className="font-semibold">{chunks}</span>,
})}
</div>
<div className="flex items-center space-x-2">
<button
@ -344,7 +339,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>Précédent</span>
<span>{t('pagination.previous')}</span>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -379,7 +374,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>Suivant</span>
<span>{t('pagination.next')}</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>

View File

@ -1,18 +1,13 @@
/**
* 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 from 'next/link';
import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
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,
@ -36,96 +31,92 @@ 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(),
});
// Prepare data for charts
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
const statusDistribution = csvKpis
? [
{ name: 'Acceptés', value: csvKpis.totalAccepted, color: '#10b981' },
{ name: 'Refusés', value: csvKpis.totalRejected, color: '#ef4444' },
{ name: 'En Attente', value: csvKpis.totalPending, color: '#f59e0b' },
{ 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' },
]
: [];
const carrierWeightData = topCarriers
? topCarriers.slice(0, 5).map(c => ({
name: c.carrierName.length > 15 ? c.carrierName.substring(0, 15) + '...' : c.carrierName,
poids: Math.round(c.totalWeightKG),
[t('charts.weightByCarrier.weight')]: 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">
{/* Header - Compact */}
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
<div>
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
<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">
Vue d'ensemble de vos réservations et performances
{t('subtitle')}
</p>
</div>
<div className="flex items-center space-x-2 sm:space-x-3">
<ExportButton
data={topCarriers || []}
filename="tableau-de-bord-transporteurs"
filename={t('exportFilename')}
columns={[
{ 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' },
{ 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' },
]}
/>
<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">Nouvelle Réservation</span>
<span className="hidden sm:inline">{t('newBooking')}</span>
</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">Acceptés</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.accepted')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -134,7 +125,7 @@ export default function DashboardPage() {
{csvKpis?.totalAccepted || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
+{csvKpis?.acceptedThisMonth || 0} ce mois
{t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth || 0 })}
</p>
</>
)}
@ -142,14 +133,13 @@ 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">Refusés</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.rejected')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -158,7 +148,7 @@ export default function DashboardPage() {
{csvKpis?.totalRejected || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
+{csvKpis?.rejectedThisMonth || 0} ce mois
{t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth || 0 })}
</p>
</>
)}
@ -166,14 +156,13 @@ 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">En Attente</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.pending')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -182,7 +171,7 @@ export default function DashboardPage() {
{csvKpis?.totalPending || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{csvKpis?.acceptanceRate.toFixed(1)}% acceptés
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
</p>
</>
)}
@ -190,20 +179,19 @@ 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">Poids Total</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.totalWeight')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
{numberFormat.format(csvKpis?.totalWeightAcceptedKG || 0)}
</p>
<p className="text-xs text-gray-500 mt-1">
KG {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
@ -215,16 +203,14 @@ 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">
Distribution des Réservations
{t('charts.distribution.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Répartition par statut
{t('charts.distribution.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -256,14 +242,13 @@ 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">
Poids par Transporteur
{t('charts.weightByCarrier.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Top 5 transporteurs par poids (KG)
{t('charts.weightByCarrier.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -282,7 +267,7 @@ export default function DashboardPage() {
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="poids" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey={weightDataKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
@ -290,7 +275,6 @@ 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">
@ -298,9 +282,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">Taux d'Acceptation</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.acceptanceRate')}</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
</p>
</div>
</CardContent>
@ -312,7 +296,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">Total Réservations</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalBookings')}</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
@ -330,7 +314,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">Volume Total</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalVolume')}</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>
@ -340,16 +324,15 @@ 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">
Top Transporteurs
{t('topCarriers.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600 mt-1">
Classement des meilleures compagnies
{t('topCarriers.description')}
</CardDescription>
</div>
<Link href="/dashboard/bookings">
@ -358,7 +341,7 @@ export default function DashboardPage() {
size="sm"
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
>
Voir tout
{t('topCarriers.viewAll')}
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
@ -390,9 +373,9 @@ export default function DashboardPage() {
{carrier.carrierName}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{carrier.totalBookings} réservations</span>
<span>{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}</span>
<span></span>
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
</div>
</div>
</div>
@ -430,15 +413,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">
Aucune réservation
{t('topCarriers.empty.title')}
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
Créez votre première réservation pour voir vos statistiques
{t('topCarriers.empty.description')}
</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" />
Créer une réservation
{t('topCarriers.empty.cta')}
</Button>
</Link>
</div>

View File

@ -1,9 +1,3 @@
/**
* User Profile Page
*
* Allows users to view and update their profile information
*/
'use client';
import { useState, useEffect } from 'react';
@ -12,45 +6,44 @@ 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('');
// Profile form
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>;
const profileForm = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
@ -60,7 +53,6 @@ export default function ProfilePage() {
},
});
// Password form
const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
@ -70,7 +62,6 @@ export default function ProfilePage() {
},
});
// Update form values when user data loads
useEffect(() => {
if (user) {
profileForm.reset({
@ -82,7 +73,6 @@ 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({
@ -94,26 +84,24 @@ 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('Profil mis à jour avec succès !');
setSuccessMessage(t('profileForm.successUpdate'));
setErrorMessage('');
refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setErrorMessage(error.message || t('profileForm.errorUpdate'));
setSuccessMessage('');
},
});
// Update password mutation
const updatePasswordMutation = useMutation({
mutationFn: async (data: PasswordFormData) => {
return changePassword({
@ -122,7 +110,7 @@ export default function ProfilePage() {
});
},
onSuccess: () => {
setSuccessMessage('Mot de passe mis à jour avec succès !');
setSuccessMessage(t('passwordForm.successUpdate'));
setErrorMessage('');
passwordForm.reset({
currentPassword: '',
@ -132,7 +120,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
setErrorMessage(error.message || t('passwordForm.errorUpdate'));
setSuccessMessage('');
},
});
@ -145,29 +133,27 @@ 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">Chargement du profil...</p>
<p className="text-gray-600">{t('loading')}</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">Impossible de charger le profil</p>
<p className="text-red-600 mb-4">{t('loadError')}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Réessayer
{t('retry')}
</button>
</div>
</div>
@ -176,13 +162,11 @@ 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">Mon Profil</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
<h1 className="text-3xl font-bold mb-2">{t('header.title')}</h1>
<p className="text-blue-100">{t('header.subtitle')}</p>
</div>
{/* Success/Error Messages */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
@ -213,7 +197,6 @@ 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">
@ -230,14 +213,13 @@ 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">
Actif
{t('active')}
</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">
@ -249,7 +231,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Informations personnelles
{t('tabs.profile')}
</button>
<button
onClick={() => setActiveTab('password')}
@ -259,7 +241,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Modifier le mot de passe
{t('tabs.password')}
</button>
</nav>
</div>
@ -268,13 +250,9 @@ 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"
>
Prénom
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.firstName')}
</label>
<input
{...profileForm.register('firstName')}
@ -289,13 +267,9 @@ export default function ProfilePage() {
)}
</div>
{/* Last Name */}
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nom
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.lastName')}
</label>
<input
{...profileForm.register('lastName')}
@ -311,10 +285,9 @@ export default function ProfilePage() {
</div>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Adresse email
{t('profileForm.email')}
</label>
<input
{...profileForm.register('email')}
@ -323,29 +296,24 @@ 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">L&apos;adresse email ne peut pas être modifiée</p>
<p className="mt-1 text-xs text-gray-500">{t('profileForm.emailHelp')}</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 ? 'Enregistrement...' : 'Enregistrer'}
{updateProfileMutation.isPending ? t('profileForm.saving') : t('profileForm.save')}
</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"
>
Mot de passe actuel
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.current')}
</label>
<input
{...passwordForm.register('currentPassword')}
@ -361,13 +329,9 @@ export default function ProfilePage() {
)}
</div>
{/* New Password */}
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nouveau mot de passe
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.new')}
</label>
<input
{...passwordForm.register('newPassword')}
@ -381,18 +345,12 @@ export default function ProfilePage() {
{passwordForm.formState.errors.newPassword.message}
</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>
<p className="mt-1 text-xs text-gray-500">{t('passwordForm.newHint')}</p>
</div>
{/* Confirm Password */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirmer le nouveau mot de passe
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.confirm')}
</label>
<input
{...passwordForm.register('confirmPassword')}
@ -408,14 +366,13 @@ 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 ? 'Mise à jour...' : 'Mettre à jour'}
{updatePasswordMutation.isPending ? t('passwordForm.submitting') : t('passwordForm.submit')}
</button>
</div>
</form>

View File

@ -1,16 +1,10 @@
/**
* 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 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import {
getAvailableOrigins,
getAvailableDestinations,
@ -18,10 +12,14 @@ import {
} from '@/lib/api/rates';
import dynamic from 'next/dynamic';
// Import dynamique pour éviter les erreurs SSR avec Leaflet
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>;
};
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
ssr: false,
loading: () => <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">Chargement de la carte...</div>,
loading: PortRouteMapLoader,
});
interface Package {
@ -35,28 +33,17 @@ 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;
@ -64,6 +51,7 @@ interface SearchForm {
}
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const router = useRouter();
const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '',
@ -101,20 +89,17 @@ 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();
@ -126,7 +111,6 @@ 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();
@ -138,10 +122,8 @@ 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
);
@ -153,7 +135,6 @@ export default function AdvancedSearchPage() {
}
}, [searchForm.origin, destinationsData]);
// Calculate total volume and weight
const calculateTotals = () => {
let totalVolumeCBM = 0;
let totalWeightKG = 0;
@ -174,7 +155,6 @@ export default function AdvancedSearchPage() {
const handleSearch = () => {
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
// Build query parameters
const params = new URLSearchParams({
origin: searchForm.origin,
destination: searchForm.destination,
@ -190,7 +170,6 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(),
});
// Redirect to results page
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
};
@ -227,13 +206,12 @@ export default function AdvancedSearchPage() {
const renderStep1 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
<h2 className="text-xl font-semibold text-gray-900">{t('step1.title')}</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">
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs"> Sélectionné</span>}
{t('step1.originLabel')} {searchForm.origin && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
</label>
<div className="relative">
<input
@ -242,7 +220,6 @@ 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);
@ -252,7 +229,7 @@ export default function AdvancedSearchPage() {
}}
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder="Rechercher un port d'origine..."
placeholder={t('step1.originPlaceholder')}
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'
}`}
@ -287,22 +264,21 @@ export default function AdvancedSearchPage() {
))}
{filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
{t('step1.moreResults', { count: filteredOrigins.length - 15 })}
</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">Aucun port d'origine trouvé pour "{originSearch}"</p>
<p className="text-sm text-gray-500">{t('step1.noOrigin', { query: 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">
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs"> Sélectionné</span>}
{t('step1.destinationLabel')} {searchForm.destination && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
</label>
<div className="relative">
<input
@ -311,7 +287,6 @@ 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);
@ -320,7 +295,7 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin}
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
placeholder={searchForm.origin ? t('step1.destinationPlaceholder') : t('step1.destinationDisabled')}
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' : ''}`}
@ -333,7 +308,7 @@ export default function AdvancedSearchPage() {
</div>
{searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1">
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
{t('step1.availableDestinations', { count: destinationsData.total, port: selectedOriginPort?.name || searchForm.origin })}
</p>
)}
{showDestinationDropdown && filteredDestinations.length > 0 && (
@ -358,28 +333,27 @@ export default function AdvancedSearchPage() {
))}
{filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
{t('step1.moreResults', { count: filteredDestinations.length - 15 })}
</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">Aucune destination trouvée pour "{destinationSearch}"</p>
<p className="text-sm text-gray-500">{t('step1.noDestination', { query: 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">
Route maritime : {selectedOriginPort.name} {selectedDestinationPort.name}
{t('step1.routeTitle', { origin: selectedOriginPort.name, destination: selectedDestinationPort.name })}
</h3>
<p className="text-xs text-gray-500 mt-1">
Distance approximative et visualisation de la route
{t('step1.routeDescription')}
</p>
</div>
<PortRouteMap
@ -398,51 +372,53 @@ export default function AdvancedSearchPage() {
</div>
);
const renderStep2 = () => (
const renderStep2 = () => {
const totals = calculateTotals();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">2. Conditionnement</h2>
<h2 className="text-xl font-semibold text-gray-900">{t('step2.title')}</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"
>
+ Ajouter un colis
{t('step2.addPackage')}
</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">Colis #{index + 1}</h3>
<h3 className="font-medium text-gray-900">{t('step2.packageNumber', { number: index + 1 })}</h3>
{searchForm.packages.length > 1 && (
<button
type="button"
onClick={() => removePackage(index)}
className="text-sm text-red-600 hover:text-red-700"
>
Supprimer
{t('step2.remove')}
</button>
)}
</div>
<div className="grid grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.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">Caisse</option>
<option value="colis">Colis</option>
<option value="palette">Palette</option>
<option value="autre">Autre</option>
<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>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Quantité</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.quantity')}</label>
<input
type="number"
min="1"
@ -453,7 +429,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">L (cm)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.length')}</label>
<input
type="number"
min="1"
@ -464,7 +440,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">l (cm)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.width')}</label>
<input
type="number"
min="1"
@ -475,7 +451,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">H (cm)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.height')}</label>
<input
type="number"
min="1"
@ -488,7 +464,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">Poids (kg)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.weight')}</label>
<input
type="number"
min="1"
@ -505,30 +481,31 @@ 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">Gerbable</label>
<label className="ml-2 text-sm text-gray-700">{t('step2.stackable')}</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">Récapitulatif</h3>
<h3 className="text-sm font-medium text-blue-900 mb-2">{t('step2.summary.title')}</h3>
<div className="text-sm text-blue-800 space-y-1">
<div>Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³</div>
<div>Poids total: {calculateTotals().totalWeightKG} kg</div>
<div>Palettes: {calculateTotals().totalPallets}</div>
<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>
</div>
</div>
);
};
const renderStep3 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">3. Options & Services</h2>
<h2 className="text-xl font-semibold text-gray-900">{t('step3.title')}</h2>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Douane Import / Export</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.customs.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -537,7 +514,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">EUR 1</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.eurDocument')}</span>
</label>
<label className="flex items-center">
<input
@ -546,7 +523,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">T1</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.t1Document')}</span>
</label>
<label className="flex items-center">
<input
@ -555,7 +532,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">Stop douane</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.customsStop')}</span>
</label>
<label className="flex items-center">
<input
@ -566,13 +543,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">Assistance export</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.exportAssistance')}</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Marchandise</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.goods.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -581,7 +558,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">Marchandise Dangereuse</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.dangerous')}</span>
</label>
<label className="flex items-center">
<input
@ -592,13 +569,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">Produits règlementés</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.regulated')}</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Manutention particulière</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.handling.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -609,7 +586,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">Manutention spéciale</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
</label>
<label className="flex items-center">
<input
@ -618,7 +595,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">Hayon</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.tailgate')}</span>
</label>
<label className="flex items-center">
<input
@ -627,7 +604,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">Sangles</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.straps')}</span>
</label>
<label className="flex items-center">
<input
@ -636,13 +613,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">Couverture thermique</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.thermalCover')}</span>
</label>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Autres options</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.other.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -651,7 +628,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">Rendez-vous livraison</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.appointment')}</span>
</label>
<label className="flex items-center">
<input
@ -660,7 +637,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">Assurance</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.insurance')}</span>
</label>
</div>
</div>
@ -670,15 +647,13 @@ 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">Recherche Avancée de Tarifs</h1>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-500 mt-1">
Formulaire complet avec toutes les options de transport
{t('subtitle')}
</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">
@ -700,13 +675,11 @@ 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"
@ -714,7 +687,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"
>
Précédent
{t('navigation.previous')}
</button>
{currentStep < 3 ? (
@ -724,7 +697,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"
>
Suivant
{t('navigation.next')}
</button>
) : (
<button
@ -733,7 +706,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" /> Rechercher les tarifs
<Search className="h-5 w-5 mr-2" /> {t('navigation.search')}
</button>
)}
</div>

View File

@ -1,7 +1,9 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
@ -13,13 +15,15 @@ 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');
@ -49,11 +53,11 @@ export default function SearchResultsPage() {
setResults(response.results);
} catch (err) {
console.error('Search error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue lors de la recherche');
setError(err instanceof Error ? err.message : t('errorGeneric'));
} finally {
setIsLoading(false);
}
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams]);
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]);
useEffect(() => {
if (!origin || !destination || !volumeCBM || !weightKG) {
@ -67,12 +71,10 @@ 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,
@ -81,7 +83,6 @@ 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);
@ -95,7 +96,7 @@ export default function SearchResultsPage() {
const bestOptions = getBestOptions();
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fr-FR', {
return new Intl.NumberFormat(dateLocale, {
style: 'currency',
currency: 'EUR',
}).format(price);
@ -107,7 +108,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">Recherche des meilleurs tarifs en cours...</p>
<p className="text-xl text-gray-700 font-medium">{t('loadingTitle')}</p>
<p className="text-gray-500 mt-2">
{origin} {destination}
</p>
@ -123,13 +124,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">Erreur</h3>
<h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</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"
>
Retour à la recherche
{t('backToSearch')}
</button>
</div>
</div>
@ -145,35 +146,30 @@ export default function SearchResultsPage() {
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
Retour à la recherche
{t('backToSearch')}
</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">Aucun résultat trouvé</h3>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
<p className="text-gray-600 mb-4">
Aucun tarif ne correspond à votre recherche pour le trajet {origin} {destination}
{t('noResultsMessage', { 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" /> 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" /> {t('suggestions')}
</h4>
<ul className="text-sm text-gray-700 space-y-2">
<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>
<li>{t('suggestionPorts')}</li>
<li>{t('suggestionVolume')}</li>
<li>{t('suggestionWeight')}</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"
>
Modifier la recherche
{t('modifySearch')}
</button>
</div>
</div>
@ -183,7 +179,7 @@ export default function SearchResultsPage() {
const optionCards = [
{
type: 'Économique',
type: t('options.economic'),
option: bestOptions?.eco,
colors: {
border: 'border-green-200',
@ -192,10 +188,10 @@ export default function SearchResultsPage() {
button: 'bg-green-600 hover:bg-green-700',
},
icon: <DollarSign className="h-10 w-10 text-green-600" />,
badge: 'Le moins cher',
badge: t('options.badgeCheapest'),
},
{
type: 'Standard',
type: t('options.standard'),
option: bestOptions?.standard,
colors: {
border: 'border-blue-200',
@ -204,10 +200,10 @@ export default function SearchResultsPage() {
button: 'bg-blue-600 hover:bg-blue-700',
},
icon: <Scale className="h-10 w-10 text-blue-600" />,
badge: 'Équilibré',
badge: t('options.badgeBalanced'),
},
{
type: 'Rapide',
type: t('options.fast'),
option: bestOptions?.fast,
colors: {
border: 'border-purple-200',
@ -216,7 +212,7 @@ export default function SearchResultsPage() {
button: 'bg-purple-600 hover:bg-purple-700',
},
icon: <Zap className="h-10 w-10 text-purple-600" />,
badge: 'Le plus rapide',
badge: t('options.badgeFastest'),
},
];
@ -229,21 +225,23 @@ export default function SearchResultsPage() {
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
Retour à la recherche
{t('backToSearch')}
</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">Résultats de recherche</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
<p className="text-gray-600">
<span className="font-semibold">{origin}</span> <span className="font-semibold">{destination}</span>{' '}
{volumeCBM} CBM {weightKG} kg
{palletCount > 0 && `${palletCount} palette${palletCount > 1 ? 's' : ''}`}
{' '}
{palletCount > 0
? t('summaryWithPallets', { volume: volumeCBM, weight: weightKG, count: palletCount })
: t('summary', { volume: volumeCBM, weight: weightKG })}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Tarifs trouvés</p>
<p className="text-sm text-gray-500">{t('ratesFound')}</p>
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
</div>
</div>
@ -255,7 +253,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" />
Meilleurs choix pour votre recherche
{t('bestChoices')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@ -282,21 +280,23 @@ 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">Prix total</p>
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</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">Transporteur :</span>
<span className="text-gray-600">{t('carrier')}</span>
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Transit :</span>
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
<span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900">
{t('transitDays', { days: card.option.transitDays })}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Type :</span>
<span className="text-gray-600">{t('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`}
>
Sélectionner cette option
{t('select')}
</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">Tous les tarifs disponibles ({results.length})</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{t('allResults', { count: results.length })}</h2>
<div className="space-y-4">
{results.map((result, index) => (
@ -335,39 +335,45 @@ 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">Prix total</p>
<p className="text-sm text-gray-500">{t('totalPrice')}</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">Prix de base</p>
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.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">Frais volume</p>
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.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">Frais poids</p>
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.weight')}</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">Délai transit</p>
<p className="font-semibold text-gray-900">{result.transitDays} jours</p>
<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>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600">
<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>}
<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>
)}
</div>
<button
onClick={() => {
@ -376,7 +382,7 @@ export default function SearchResultsPage() {
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Sélectionner
{t('selectShort')}
</button>
</div>
</div>

View File

@ -2,6 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys';
import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys';
import { useSubscription } from '@/lib/context/subscription-context';
@ -19,39 +20,8 @@ import {
} from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(iso));
}
function keyStatusBadge(key: ApiKeyDto) {
if (!key.isActive) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
Révoquée
</span>
);
}
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
Expirée
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Active
</span>
);
}
// ─── Copy button ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const t = useTranslations('dashboard.apiKeys.copy');
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
@ -66,20 +36,18 @@ function CopyButton({ text }: { text: string }) {
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-600">Copié</span>
<span className="text-green-600">{t('copied')}</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-600">Copier</span>
<span className="text-gray-600">{t('copy')}</span>
</>
)}
</button>
);
}
// ─── Creation success modal ──────────────────────────────────────────────────
function CreatedKeyModal({
result,
onClose,
@ -87,17 +55,17 @@ function CreatedKeyModal({
result: CreateApiKeyResultDto;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.createdModal');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-green-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Clé API créée</h2>
<h2 className="text-lg font-semibold text-gray-900">{t('title')}</h2>
<p className="text-sm text-gray-500">{result.name}</p>
</div>
</div>
@ -106,19 +74,16 @@ function CreatedKeyModal({
</button>
</div>
{/* Warning */}
<div className="mx-6 mt-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
<strong>Copiez cette clé maintenant.</strong> Elle ne sera plus jamais affichée après
la fermeture de cette fenêtre.
<strong>{t('warning')}</strong> {t('warningRest')}
</p>
</div>
{/* Key */}
<div className="p-6">
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
Clé API complète
{t('fullKey')}
</label>
<div className="flex items-center gap-2 p-3 bg-gray-950 rounded-xl border border-gray-800">
<code className="flex-1 text-xs font-mono text-green-400 break-all">
@ -126,18 +91,15 @@ function CreatedKeyModal({
</code>
<CopyButton text={result.fullKey} />
</div>
<p className="mt-3 text-xs text-gray-500">
Stockez-la dans vos variables d&apos;environnement ou un gestionnaire de secrets.
</p>
<p className="mt-3 text-xs text-gray-500">{t('storeHint')}</p>
</div>
{/* Footer */}
<div className="p-6 pt-0">
<button
onClick={onClose}
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
>
J&apos;ai copié ma clé, fermer
{t('close')}
</button>
</div>
</div>
@ -145,8 +107,6 @@ function CreatedKeyModal({
);
}
// ─── Create key form modal ───────────────────────────────────────────────────
function CreateKeyModal({
onSuccess,
onClose,
@ -154,6 +114,7 @@ function CreateKeyModal({
onSuccess: (result: CreateApiKeyResultDto) => void;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.createModal');
const [name, setName] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const queryClient = useQueryClient();
@ -177,13 +138,12 @@ function CreateKeyModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Nouvelle clé API</h2>
<h2 className="text-lg font-semibold text-gray-900">{t('title')}</h2>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<X className="w-5 h-5" />
@ -191,28 +151,26 @@ function CreateKeyModal({
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Nom de la clé <span className="text-red-500">*</span>
{t('name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="ex: Intégration ERP Production"
placeholder={t('namePlaceholder')}
maxLength={100}
required
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">{name.length}/100 caractères</p>
<p className="mt-1 text-xs text-gray-400">{t('nameCount', { count: name.length })}</p>
</div>
{/* Expiry */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Date d&apos;expiration{' '}
<span className="text-gray-400 font-normal">(optionnel)</span>
{t('expiry')}{' '}
<span className="text-gray-400 font-normal">{t('optional')}</span>
</label>
<input
type="date"
@ -221,34 +179,30 @@ function CreateKeyModal({
min={new Date().toISOString().split('T')[0]}
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">
Si vide, la clé n&apos;expire jamais.
</p>
<p className="mt-1 text-xs text-gray-400">{t('expiryHint')}</p>
</div>
{/* Error */}
{mutation.isError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
Une erreur est survenue. Veuillez réessayer.
{t('errorGeneric')}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
>
Annuler
{t('cancel')}
</button>
<button
type="submit"
disabled={!name.trim() || mutation.isPending}
className="flex-1 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
{mutation.isPending ? 'Création...' : 'Créer la clé'}
{mutation.isPending ? t('creating') : t('create')}
</button>
</div>
</form>
@ -257,8 +211,6 @@ function CreateKeyModal({
);
}
// ─── Revoke confirm modal ────────────────────────────────────────────────────
function RevokeConfirmModal({
apiKey,
onConfirm,
@ -268,6 +220,7 @@ function RevokeConfirmModal({
onConfirm: () => void;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.revokeModal');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
@ -275,15 +228,13 @@ function RevokeConfirmModal({
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">
Révoquer cette clé ?
</h2>
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">{t('title')}</h2>
<p className="text-sm text-gray-600 text-center mb-1">
<strong className="text-gray-900">{apiKey.name}</strong>
</p>
<p className="text-sm text-gray-500 text-center">
Cette action est <strong>immédiate et irréversible</strong>. Toute requête utilisant
cette clé sera refusée.
{t('description')} <strong>{t('descriptionEmphasis')}</strong>
{t('descriptionRest')}
</p>
</div>
<div className="px-6 pb-6 flex gap-3">
@ -291,13 +242,13 @@ function RevokeConfirmModal({
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
>
Annuler
{t('cancel')}
</button>
<button
onClick={onConfirm}
className="flex-1 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Révoquer
{t('confirm')}
</button>
</div>
</div>
@ -305,9 +256,10 @@ function RevokeConfirmModal({
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ApiKeysPage() {
const t = useTranslations('dashboard.apiKeys');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const { hasFeature } = useSubscription();
const queryClient = useQueryClient();
const hasApiAccess = hasFeature('api_access');
@ -330,23 +282,52 @@ export default function ApiKeysPage() {
},
});
// Plan upsell screen
const formatDate = (iso: string | null): string => {
if (!iso) return '—';
return new Intl.DateTimeFormat(dateLocale, { dateStyle: 'medium' }).format(new Date(iso));
};
const keyStatusBadge = (key: ApiKeyDto) => {
if (!key.isActive) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('status.revoked')}
</span>
);
}
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
{t('status.expired')}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
{t('status.active')}
</span>
);
};
if (!hasApiAccess) {
return (
<div className="max-w-lg mx-auto mt-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-gray-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3">Accès API</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-3">{t('noAccess.title')}</h1>
<p className="text-gray-600 mb-8">
L&apos;accès programmatique à l&apos;API Xpeditis est disponible sur les plans{' '}
<strong>Gold</strong> et <strong>Platinium</strong> uniquement.
{t.rich('noAccess.description', {
gold: () => <strong>{t('noAccess.gold')}</strong>,
platinium: () => <strong>{t('noAccess.platinium')}</strong>,
})}
</p>
<a
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
>
Voir les plans
{t('noAccess.viewPlans')}
</a>
</div>
);
@ -356,7 +337,6 @@ export default function ApiKeysPage() {
return (
<>
{/* Modals */}
{showCreateModal && (
<CreateKeyModal
onSuccess={result => {
@ -378,8 +358,8 @@ export default function ApiKeysPage() {
)}
<PageHeader
title="Clés API"
description="Gérez les clés d'accès programmatique à l'API Xpeditis."
title={t('title')}
description={t('description')}
actions={
<button
onClick={() => setShowCreateModal(true)}
@ -387,33 +367,35 @@ export default function ApiKeysPage() {
className="flex items-center gap-2 px-4 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
<Plus className="w-4 h-4" />
Nouvelle clé
{t('newKey')}
</button>
}
/>
{/* Info banner */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-100 rounded-xl flex gap-3">
<ShieldCheck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-0.5">Comment utiliser vos clés API</p>
<p className="font-medium mb-0.5">{t('infoTitle')}</p>
<p>
Ajoutez l&apos;en-tête{' '}
{t.rich('infoBody', {
code: () => (
<code className="px-1.5 py-0.5 bg-blue-100 rounded text-blue-900 font-mono text-xs">
X-API-Key: xped_live_...
</code>{' '}
à chaque requête HTTP.{' '}
</code>
),
link: () => (
<a
href="/dashboard/docs?section=authentication"
className="font-medium underline underline-offset-2"
>
Voir la documentation
{t('viewDocs')}
</a>
),
})}
</p>
</div>
</div>
{/* Keys list */}
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16">
@ -422,22 +404,21 @@ export default function ApiKeysPage() {
) : !apiKeys || apiKeys.length === 0 ? (
<div className="py-16 text-center">
<Key className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">Aucune clé API pour le moment.</p>
<p className="text-gray-500 text-sm">{t('noKeys')}</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
>
Créer votre première clé
{t('createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{/* Table header */}
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 px-6 py-3 bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wide">
<span>Nom / Préfixe</span>
<span>Dernière utilisation</span>
<span>Expiration</span>
<span>Statut</span>
<span>{t('table.name')}</span>
<span>{t('table.lastUsed')}</span>
<span>{t('table.expiry')}</span>
<span>{t('table.status')}</span>
<span />
</div>
@ -446,26 +427,19 @@ export default function ApiKeysPage() {
key={key.id}
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
>
{/* Name + prefix */}
<div>
<p className="text-sm font-medium text-gray-900">{key.name}</p>
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}</code>
</div>
{/* Last used */}
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
{/* Expiry */}
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
{/* Status */}
<div>{keyStatusBadge(key)}</div>
{/* Actions */}
<button
onClick={() => setRevokeTarget(key)}
disabled={!key.isActive || revokeMutation.isPending}
title="Révoquer cette clé"
title={t('revoke')}
className="p-2 text-gray-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
@ -476,10 +450,9 @@ export default function ApiKeysPage() {
)}
</div>
{/* Quota */}
{apiKeys && apiKeys.length > 0 && (
<p className="mt-4 text-xs text-gray-400 text-right">
{activeKeys.length} / 20 clés actives utilisées
{t('quota', { active: activeKeys.length, max: 20 })}
</p>
)}
</>

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/context/auth-context';
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
import type { OrganizationResponse } from '@/types/api';
@ -23,11 +24,11 @@ interface OrganizationForm {
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
export default function OrganizationSettingsPage() {
const t = useTranslations('dashboard.organizationSettings');
const { user } = useAuth();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<TabType>('information');
// Auto-switch to subscription tab if coming back from Stripe (only for ADMIN/MANAGER)
useEffect(() => {
const isSuccess = searchParams.get('success') === 'true';
const isCanceled = searchParams.get('canceled') === 'true';
@ -53,7 +54,6 @@ export default function OrganizationSettingsPage() {
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Check if user can edit organization (only ADMIN and MANAGER)
const canEdit = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const loadOrganization = useCallback(async () => {
@ -77,11 +77,11 @@ export default function OrganizationSettingsPage() {
});
} catch (err) {
console.error('Failed to load organization:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, t]);
useEffect(() => {
if (user?.organizationId) {
@ -135,10 +135,10 @@ export default function OrganizationSettingsPage() {
});
setOrganization(updatedOrg);
setSuccessMessage('Informations sauvegardées avec succès');
setSuccessMessage(t('saveSuccess'));
} catch (err) {
console.error('Failed to update organization:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de la sauvegarde');
setError(err instanceof Error ? err.message : t('saveFailed'));
} finally {
setIsSaving(false);
}
@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
<p className="text-gray-600">Chargement...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -159,20 +159,19 @@ export default function OrganizationSettingsPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700">{error || "Impossible de charger l'organisation"}</p>
<h3 className="text-lg font-semibold text-red-900 mb-2">{t('errorTitle')}</h3>
<p className="text-red-700">{error || t('loadError')}</p>
</div>
</div>
);
}
// Check if user can view subscription and licenses (only ADMIN and MANAGER)
const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const tabs = [
{
id: 'information' as TabType,
label: 'Informations',
label: t('tabs.information'),
icon: (
<svg className="w-5 h-5" 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" />
@ -181,7 +180,7 @@ export default function OrganizationSettingsPage() {
},
{
id: 'address' as TabType,
label: 'Adresse',
label: t('tabs.address'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
@ -189,11 +188,10 @@ export default function OrganizationSettingsPage() {
</svg>
),
},
// Only show subscription and licenses tabs for ADMIN and MANAGER roles
...(canViewBilling ? [
{
id: 'subscription' as TabType,
label: 'Abonnement',
label: t('tabs.subscription'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
@ -202,7 +200,7 @@ export default function OrganizationSettingsPage() {
},
{
id: 'licenses' as TabType,
label: 'Licences',
label: t('tabs.licenses'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
@ -214,13 +212,11 @@ export default function OrganizationSettingsPage() {
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l&apos;organisation</h1>
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
<h1 className="text-3xl font-bold text-gray-900">{t('header.title')}</h1>
<p className="text-gray-600 mt-2">{t('header.subtitle')}</p>
</div>
{/* Success Message */}
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
@ -232,7 +228,6 @@ export default function OrganizationSettingsPage() {
</div>
)}
{/* Error Message */}
{error && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
@ -244,19 +239,17 @@ export default function OrganizationSettingsPage() {
</div>
)}
{/* Read-only warning for USER role */}
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-blue-600 mr-3" 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>
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l&apos;organisation</p>
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
</div>
</div>
)}
{/* Tabs */}
<div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200">
<nav className="flex -mb-px overflow-x-auto">
@ -279,14 +272,12 @@ export default function OrganizationSettingsPage() {
</nav>
</div>
{/* Tab Content */}
<div className="p-8">
{activeTab === 'information' && (
<div className="space-y-6">
{/* Nom de la société */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nom de la société <span className="text-red-500">*</span>
{t('information.name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -294,16 +285,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('name', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="Xpeditis"
placeholder={t('information.namePlaceholder')}
required
/>
</div>
{/* SIREN */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
SIREN
<span className="ml-2 text-xs text-gray-500">(Système d&apos;Identification du Répertoire des Entreprises)</span>
{t('information.siren')}
<span className="ml-2 text-xs text-gray-500">({t('information.sirenHint')})</span>
</label>
<input
type="text"
@ -311,17 +301,16 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="123 456 789"
placeholder={t('information.sirenPlaceholder')}
maxLength={9}
/>
<p className="mt-1 text-xs text-gray-500">9 chiffres</p>
<p className="mt-1 text-xs text-gray-500">{t('information.sirenDigits')}</p>
</div>
{/* Numéro EORI */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Numéro EORI
<span className="ml-2 text-xs text-gray-500">(Economic Operators Registration and Identification)</span>
{t('information.eori')}
<span className="ml-2 text-xs text-gray-500">({t('information.eoriHint')})</span>
</label>
<input
type="text"
@ -329,35 +318,33 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('eori', e.target.value.toUpperCase())}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="FR123456789"
placeholder={t('information.eoriPlaceholder')}
maxLength={17}
/>
<p className="mt-1 text-xs text-gray-500">Code pays (2 lettres) + numéro unique (max 15 caractères)</p>
<p className="mt-1 text-xs text-gray-500">{t('information.eoriHelp')}</p>
</div>
{/* Téléphone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Téléphone</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.phone')}</label>
<input
type="tel"
value={formData.contact_phone}
onChange={e => handleChange('contact_phone', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="+33 6 80 18 28 12"
placeholder={t('information.phonePlaceholder')}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.email')}</label>
<input
type="email"
value={formData.contact_email}
onChange={e => handleChange('contact_email', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="contact@xpeditis.com"
placeholder={t('information.emailPlaceholder')}
/>
</div>
</div>
@ -365,10 +352,9 @@ export default function OrganizationSettingsPage() {
{activeTab === 'address' && (
<div className="space-y-6">
{/* Rue */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rue <span className="text-red-500">*</span>
{t('address.street')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -376,16 +362,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_street', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="123 Rue de la Paix"
placeholder={t('address.streetPlaceholder')}
required
/>
</div>
{/* Ville et Code postal */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Code postal <span className="text-red-500">*</span>
{t('address.postalCode')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -393,13 +378,13 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_postal_code', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="75001"
placeholder={t('address.postalCodePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ville <span className="text-red-500">*</span>
{t('address.city')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -407,16 +392,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_city', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="Paris"
placeholder={t('address.cityPlaceholder')}
required
/>
</div>
</div>
{/* Pays */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Pays <span className="text-red-500">*</span>
{t('address.country')} <span className="text-red-500">*</span>
</label>
<select
value={formData.address_country}
@ -425,15 +409,15 @@ export default function OrganizationSettingsPage() {
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
>
<option value="FR">France</option>
<option value="BE">Belgique</option>
<option value="DE">Allemagne</option>
<option value="ES">Espagne</option>
<option value="IT">Italie</option>
<option value="NL">Pays-Bas</option>
<option value="GB">Royaume-Uni</option>
<option value="US">États-Unis</option>
<option value="CN">Chine</option>
<option value="FR">{t('address.countries.FR')}</option>
<option value="BE">{t('address.countries.BE')}</option>
<option value="DE">{t('address.countries.DE')}</option>
<option value="ES">{t('address.countries.ES')}</option>
<option value="IT">{t('address.countries.IT')}</option>
<option value="NL">{t('address.countries.NL')}</option>
<option value="GB">{t('address.countries.GB')}</option>
<option value="US">{t('address.countries.US')}</option>
<option value="CN">{t('address.countries.CN')}</option>
</select>
</div>
</div>
@ -444,7 +428,6 @@ export default function OrganizationSettingsPage() {
{activeTab === 'licenses' && canViewBilling && <LicensesTab />}
</div>
{/* Actions (only for information and address tabs) */}
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
<button
@ -453,7 +436,7 @@ export default function OrganizationSettingsPage() {
disabled={isSaving}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Annuler
{t('actions.cancel')}
</button>
<button
type="button"
@ -464,10 +447,10 @@ export default function OrganizationSettingsPage() {
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Enregistrement...
{t('actions.saving')}
</>
) : (
'Enregistrer'
t('actions.save')
)}
</button>
</div>

View File

@ -1,20 +1,16 @@
/**
* Subscription Management Page
*
* Redirects to Organization settings with Subscription tab
*/
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
export default function SubscriptionPage() {
const t = useTranslations('dashboard.subscriptionRedirect');
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
// Preserve any query parameters (success, canceled) from Stripe redirects
const params = searchParams.toString();
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
router.replace(redirectUrl);
@ -24,7 +20,7 @@ export default function SubscriptionPage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
<p className="text-gray-600">Redirection...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);

View File

@ -1,18 +1,12 @@
/**
* User Management Page
*
* Manage organization users, roles, and invitations
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
import { Link, useRouter } from '@/i18n/navigation';
import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
@ -27,13 +21,18 @@ function Pagination({
total: number;
onPage: (p: number) => void;
}) {
const t = useTranslations('dashboard.usersManagement.pagination');
const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) return null;
return (
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-500">
{Math.min((page - 1) * PAGE_SIZE + 1, total)}{Math.min(page * PAGE_SIZE, total)} sur {total}
{t('info', {
from: Math.min((page - 1) * PAGE_SIZE + 1, total),
to: Math.min(page * PAGE_SIZE, total),
total,
})}
</p>
<div className="flex items-center gap-1">
<button
@ -69,6 +68,9 @@ function Pagination({
}
export default function UsersManagementPage() {
const t = useTranslations('dashboard.usersManagement');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
@ -107,14 +109,14 @@ export default function UsersManagementPage() {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
queryClient.invalidateQueries({ queryKey: ['invitations'] });
setSuccess("Invitation envoyée avec succès ! L'utilisateur recevra un email avec un lien d'inscription.");
setSuccess(t('messages.inviteSuccess'));
setShowInviteModal(false);
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
setInvitationsPage(1);
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || "Échec de l'envoi de l'invitation");
setError(err.response?.data?.message || t('messages.inviteError'));
setTimeout(() => setError(''), 5000);
},
});
@ -124,11 +126,11 @@ export default function UsersManagementPage() {
updateUser(id, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Rôle mis à jour avec succès');
setSuccess(t('messages.roleSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
setError(err.response?.data?.message || t('messages.roleError'));
setTimeout(() => setError(''), 5000);
},
});
@ -139,11 +141,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess("Statut de l'utilisateur mis à jour avec succès");
setSuccess(t('messages.statusSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
setError(err.response?.data?.message || t('messages.statusError'));
setTimeout(() => setError(''), 5000);
},
});
@ -153,11 +155,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Utilisateur supprimé avec succès');
setSuccess(t('messages.deleteSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || "Échec de la suppression de l'utilisateur");
setError(err.response?.data?.message || t('messages.deleteError'));
setTimeout(() => setError(''), 5000);
},
});
@ -167,11 +169,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invitations'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation annulée avec succès');
setSuccess(t('messages.cancelInviteSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || "Échec de l'annulation de l'invitation");
setError(err.response?.data?.message || t('messages.cancelInviteError'));
setTimeout(() => setError(''), 5000);
},
});
@ -201,19 +203,20 @@ export default function UsersManagementPage() {
};
const handleToggleActive = (userId: string, isActive: boolean) => {
if (window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)) {
const action = isActive ? t('confirms.toggleDeactivate') : t('confirms.toggleActivate');
if (window.confirm(t('confirms.toggleActive', { action }))) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
};
const handleDelete = (userId: string) => {
if (window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) {
if (window.confirm(t('confirms.delete'))) {
deleteMutation.mutate(userId);
}
};
const handleCancelInvitation = (invId: string, name: string) => {
if (window.confirm(`Annuler l'invitation envoyée à ${name} ?`)) {
if (window.confirm(t('confirms.cancelInvite', { name }))) {
cancelInvitationMutation.mutate(invId);
}
};
@ -236,7 +239,6 @@ export default function UsersManagementPage() {
return (
<div className="space-y-6">
{/* License Warning */}
{licenseStatus && !licenseStatus.canInvite && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start">
@ -246,14 +248,13 @@ export default function UsersManagementPage() {
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
<p className="mt-1 text-sm text-amber-700">
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
{t('license.limitMessage', { used: licenseStatus.usedLicenses, max: licenseStatus.maxLicenses })}
</p>
<div className="mt-3">
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline">
Mettre à niveau l'abonnement
{t('license.upgradeLink')}
</Link>
</div>
</div>
@ -261,7 +262,6 @@ export default function UsersManagementPage() {
</div>
)}
{/* License Usage Info */}
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
@ -270,31 +270,35 @@ export default function UsersManagementPage() {
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span className="text-sm text-blue-800">
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
{t('license.remaining', {
count: licenseStatus.availableLicenses,
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</span>
</div>
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
Gérer l'abonnement
{t('license.manageLink')}
</Link>
</div>
</div>
)}
<PageHeader
title="Gestion des Utilisateurs"
description="Gérez les membres de l'équipe et leurs permissions"
title={t('header.title')}
description={t('header.subtitle')}
actions={
<>
<ExportButton
data={allUsers}
filename="utilisateurs"
filename={t('exportFilename')}
columns={[
{ key: 'firstName', label: 'Prénom' },
{ key: 'lastName', label: 'Nom' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) },
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
{ key: 'firstName', label: t('export.firstName') },
{ key: 'lastName', label: t('export.lastName') },
{ key: 'email', label: t('export.email') },
{ key: 'role', label: t('export.role'), format: (v) => t(`modal.rolesExport.${v}` as any) || v },
{ key: 'isActive', label: t('export.status'), format: (v) => v ? t('users.active') : t('users.inactive') },
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
]}
/>
{licenseStatus?.canInvite ? (
@ -303,8 +307,8 @@ export default function UsersManagementPage() {
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">Inviter un utilisateur</span>
<span className="sm:hidden">Inviter</span>
<span className="hidden sm:inline">{t('actions.invite')}</span>
<span className="sm:hidden">{t('actions.inviteShort')}</span>
</button>
) : (
<Link
@ -312,8 +316,8 @@ export default function UsersManagementPage() {
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">Mettre à niveau</span>
<span className="sm:hidden">Upgrade</span>
<span className="hidden sm:inline">{t('actions.upgrade')}</span>
<span className="sm:hidden">{t('actions.upgradeShort')}</span>
</Link>
)}
</>
@ -332,18 +336,17 @@ export default function UsersManagementPage() {
</div>
)}
{/* Users Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Utilisateurs</h2>
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
{allUsers.length > 0 && (
<p className="text-sm text-gray-500 mt-1">{allUsers.length} membre{allUsers.length !== 1 ? 's' : ''}</p>
<p className="text-sm text-gray-500 mt-1">{t('users.membersCount', { count: allUsers.length })}</p>
)}
</div>
{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>
Chargement des utilisateurs...
{t('loading')}
</div>
) : pagedUsers.length > 0 ? (
<>
@ -351,12 +354,12 @@ export default function UsersManagementPage() {
<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">Utilisateur</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date de création</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.user')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.email')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.role')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.status')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.createdAt')}</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.actions')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -387,19 +390,19 @@ export default function UsersManagementPage() {
user.id === currentUser?.id
}
>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="MANAGER">Manager</option>
<option value="USER">User</option>
<option value="VIEWER">Viewer</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">{t('modal.roles.ADMIN')}</option>}
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 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 ? 'Actif' : 'Inactif'}
{user.isActive ? t('users.active') : t('users.inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
{new Date(user.createdAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@ -432,18 +435,18 @@ export default function UsersManagementPage() {
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button onClick={() => setShowInviteModal(true)} 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">
<span className="mr-2">+</span>
Inviter un utilisateur
{t('actions.invite')}
</button>
) : (
<Link href="/dashboard/settings/subscription" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
<span className="mr-2">+</span>
Mettre à niveau
{t('actions.upgrade')}
</Link>
)}
</div>
@ -451,25 +454,24 @@ export default function UsersManagementPage() {
)}
</div>
{/* Pending Invitations */}
{allPending.length > 0 && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Invitations en attente</h2>
<h2 className="text-lg font-medium text-gray-900">{t('invitations.title')}</h2>
<p className="text-sm text-gray-500 mt-1">
Utilisateurs invités mais n'ayant pas encore créé leur compte — {allPending.length} invitation{allPending.length !== 1 ? 's' : ''}
{t('invitations.subtitle', { count: allPending.length })}
</p>
</div>
<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">Utilisateur</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expire le</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-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.user')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.email')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.role')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.expires')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.status')}</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.actions')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -494,11 +496,11 @@ export default function UsersManagementPage() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(inv.expiresAt).toLocaleDateString('fr-FR')}
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>
{isExpired ? 'Expirée' : 'En attente'}
{isExpired ? t('invitations.expired') : t('invitations.pending')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
@ -510,7 +512,7 @@ export default function UsersManagementPage() {
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Annuler
{t('invitations.cancel')}
</button>
</td>
</tr>
@ -523,7 +525,6 @@ export default function UsersManagementPage() {
</div>
)}
{/* Actions Menu Modal */}
{openMenuId && menuPosition && (
<>
<div
@ -550,14 +551,14 @@ export default function UsersManagementPage() {
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<span className="text-sm font-medium text-gray-700">Désactiver</span>
<span className="text-sm font-medium text-gray-700">{t('users.actions.deactivate')}</span>
</>
) : (
<>
<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-gray-700">Activer</span>
<span className="text-sm font-medium text-gray-700">{t('users.actions.activate')}</span>
</>
)}
</button>
@ -573,14 +574,13 @@ export default function UsersManagementPage() {
<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">Supprimer</span>
<span className="text-sm font-medium text-red-600">{t('users.actions.delete')}</span>
</button>
</div>
</div>
</>
)}
{/* Invite Modal */}
{showInviteModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -588,7 +588,7 @@ export default function UsersManagementPage() {
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 hover:text-gray-500">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -598,7 +598,7 @@ export default function UsersManagementPage() {
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Prénom *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')} *</label>
<input
type="text"
required
@ -608,7 +608,7 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Nom *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')} *</label>
<input
type="text"
required
@ -619,7 +619,7 @@ export default function UsersManagementPage() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')} *</label>
<input
type="email"
required
@ -629,15 +629,15 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')} *</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="USER">Utilisateur</option>
<option value="MANAGER">Manager</option>
<option value="VIEWER">Lecteur</option>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
@ -646,14 +646,14 @@ export default function UsersManagementPage() {
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
>
{inviteMutation.isPending ? 'Envoi en cours...' : "Envoyer l'invitation"}
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
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 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Annuler
{t('modal.cancel')}
</button>
</div>
</form>

View File

@ -1,13 +1,7 @@
/**
* Track & Trace Page
*
* Allows users to track their shipments by entering tracking numbers
* and selecting the carrier. Includes search history and vessel position map.
*/
'use client';
import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@ -18,7 +12,6 @@ import {
ClipboardList,
Lightbulb,
History,
MapPin,
X,
Clock,
Ship,
@ -29,7 +22,6 @@ import {
Anchor,
} from 'lucide-react';
// Search history item type
interface SearchHistoryItem {
id: string;
trackingNumber: string;
@ -38,114 +30,26 @@ interface SearchHistoryItem {
timestamp: Date;
}
// Carrier tracking URLs with official brand colors
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
const carriers = [
{
id: 'maersk',
name: 'Maersk',
color: '#00243D', // Maersk dark blue
textColor: 'text-white',
trackingUrl: 'https://www.maersk.com/tracking/',
placeholder: 'Ex: MSKU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/maersk.svg',
},
{
id: 'msc',
name: 'MSC',
color: '#002B5C', // MSC blue
textColor: 'text-white',
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
placeholder: 'Ex: MSCU1234567',
description: 'N° conteneur, B/L ou réservation',
logo: '/assets/logos/carriers/msc.svg',
},
{
id: 'cma-cgm',
name: 'CMA CGM',
color: '#E30613', // CMA CGM red
textColor: 'text-white',
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
placeholder: 'Ex: CMAU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cmacgm.svg',
},
{
id: 'hapag-lloyd',
name: 'Hapag-Lloyd',
color: '#FF6600', // Hapag orange
textColor: 'text-white',
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
placeholder: 'Ex: HLCU1234567',
description: 'N° conteneur',
logo: '/assets/logos/carriers/hapag.svg',
},
{
id: 'cosco',
name: 'COSCO',
color: '#003A70', // COSCO blue
textColor: 'text-white',
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
placeholder: 'Ex: COSU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cosco.svg',
},
{
id: 'one',
name: 'ONE',
color: '#FF00FF', // ONE magenta
textColor: 'text-white',
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
placeholder: 'Ex: ONEU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/one.svg',
},
{
id: 'evergreen',
name: 'Evergreen',
color: '#006633', // Evergreen green
textColor: 'text-white',
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
placeholder: 'Ex: EGHU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/evergreen.svg',
},
{
id: 'yangming',
name: 'Yang Ming',
color: '#FFD700', // Yang Ming yellow
textColor: 'text-gray-900',
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
description: 'N° conteneur',
logo: '/assets/logos/carriers/yangming.svg',
},
{
id: 'zim',
name: 'ZIM',
color: '#1E3A8A', // ZIM blue
textColor: 'text-white',
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
placeholder: 'Ex: ZIMU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/zim.svg',
},
{
id: 'hmm',
name: 'HMM',
color: '#E65100', // HMM orange
textColor: 'text-white',
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
placeholder: 'Ex: HDMU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/hmm.svg',
},
{ id: 'maersk', name: 'Maersk', color: '#00243D', textColor: 'text-white', trackingUrl: 'https://www.maersk.com/tracking/', placeholder: 'Ex: MSKU1234567', descKey: 'containerOrBl' as CarrierDescKey },
{ id: 'msc', name: 'MSC', color: '#002B5C', textColor: 'text-white', trackingUrl: 'https://www.msc.com/track-a-shipment?query=', placeholder: 'Ex: MSCU1234567', descKey: 'containerBlOrBooking' as CarrierDescKey },
{ id: 'cma-cgm', name: 'CMA CGM', color: '#E30613', textColor: 'text-white', trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=', placeholder: 'Ex: CMAU1234567', descKey: 'containerOrBl' as CarrierDescKey },
{ id: 'hapag-lloyd', name: 'Hapag-Lloyd', color: '#FF6600', textColor: 'text-white', trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=', placeholder: 'Ex: HLCU1234567', descKey: 'containerOnly' as CarrierDescKey },
{ id: 'cosco', name: 'COSCO', color: '#003A70', textColor: 'text-white', trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=', placeholder: 'Ex: COSU1234567', descKey: 'containerOrBl' as CarrierDescKey },
{ id: 'one', name: 'ONE', color: '#FF00FF', textColor: 'text-white', trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=', placeholder: 'Ex: ONEU1234567', descKey: 'containerOrBl' as CarrierDescKey },
{ id: 'evergreen', name: 'Evergreen', color: '#006633', textColor: 'text-white', trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=', placeholder: 'Ex: EGHU1234567', descKey: 'containerOrBl' as CarrierDescKey },
{ id: 'yangming', name: 'Yang Ming', color: '#FFD700', textColor: 'text-gray-900', trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=', placeholder: 'Ex: YMLU1234567', descKey: 'containerOnly' as CarrierDescKey },
{ id: 'zim', name: 'ZIM', color: '#1E3A8A', textColor: 'text-white', trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=', placeholder: 'Ex: ZIMU1234567', descKey: 'containerOrBl' as CarrierDescKey },
{ id: 'hmm', name: 'HMM', color: '#E65100', textColor: 'text-white', trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=', placeholder: 'Ex: HDMU1234567', descKey: 'containerOrBl' as CarrierDescKey },
];
// Local storage keys
const HISTORY_KEY = 'xpeditis_track_history';
export default function TrackTracePage() {
const t = useTranslations('dashboard.trackTrace');
const locale = useLocale();
const [trackingNumber, setTrackingNumber] = useState('');
const [selectedCarrier, setSelectedCarrier] = useState('');
const [error, setError] = useState('');
@ -154,7 +58,6 @@ export default function TrackTracePage() {
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const [isMapLoading, setIsMapLoading] = useState(true);
// Load history from localStorage on mount
useEffect(() => {
const savedHistory = localStorage.getItem(HISTORY_KEY);
if (savedHistory) {
@ -170,7 +73,6 @@ export default function TrackTracePage() {
}
}, []);
// Save to localStorage
const saveHistory = (history: SearchHistoryItem[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
setSearchHistory(history);
@ -178,11 +80,11 @@ export default function TrackTracePage() {
const handleTrack = () => {
if (!trackingNumber.trim()) {
setError('Veuillez entrer un numéro de tracking');
setError(t('errors.noTrackingNumber'));
return;
}
if (!selectedCarrier) {
setError('Veuillez sélectionner un transporteur');
setError(t('errors.noCarrier'));
return;
}
@ -190,7 +92,6 @@ export default function TrackTracePage() {
const carrier = carriers.find(c => c.id === selectedCarrier);
if (carrier) {
// Add to history
const newHistoryItem: SearchHistoryItem = {
id: Date.now().toString(),
trackingNumber: trackingNumber.trim(),
@ -199,7 +100,6 @@ export default function TrackTracePage() {
timestamp: new Date(),
};
// Keep only last 10 unique searches
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
)].slice(0, 10);
@ -240,21 +140,19 @@ export default function TrackTracePage() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'À 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');
if (diffMins < 1) return t('timeAgo.justNow');
if (diffMins < 60) return t('timeAgo.minutesAgo', { count: diffMins });
if (diffHours < 24) return t('timeAgo.hoursAgo', { count: diffHours });
if (diffDays < 7) return t('timeAgo.daysAgo', { count: diffDays });
return date.toLocaleDateString(locale);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1>
<p className="mt-2 text-gray-600">
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
</p>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-2 text-gray-600">{t('description')}</p>
</div>
{/* Search Form */}
@ -262,17 +160,15 @@ export default function TrackTracePage() {
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Search className="h-5 w-5 text-blue-600" />
Rechercher une expédition
{t('searchCard.title')}
</CardTitle>
<CardDescription>
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
</CardDescription>
<CardDescription>{t('searchCard.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */}
{/* Carrier Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Sélectionnez le transporteur
{t('searchCard.selectCarrier')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{carriers.map(carrier => (
@ -289,7 +185,6 @@ export default function TrackTracePage() {
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
}`}
>
{/* Carrier logo/badge with brand color */}
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
style={{ backgroundColor: carrier.color }}
@ -305,7 +200,7 @@ export default function TrackTracePage() {
{/* Tracking Number Input */}
<div>
<label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2">
Numéro de tracking
{t('searchCard.trackingNumber')}
</label>
<div className="flex gap-3">
<div className="flex-1">
@ -322,22 +217,23 @@ export default function TrackTracePage() {
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
/>
{selectedCarrierData && (
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
<p className="mt-1 text-xs text-gray-500">
{t(`carriers.${selectedCarrierData.descKey}` as any)}
</p>
)}
</div>
{/* US 5.2: Harmonized button color */}
<Button
onClick={handleTrack}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
>
<Search className="mr-2 h-5 w-5" />
Rechercher
{t('searchCard.searchButton')}
</Button>
</div>
</div>
{/* Action Button - Map */}
{/* Map Toggle */}
<div className="flex flex-wrap gap-3 pt-2">
<Button
variant={showMap ? "default" : "outline"}
@ -351,7 +247,7 @@ export default function TrackTracePage() {
}
>
<Globe className="mr-2 h-4 w-4" />
{showMap ? 'Masquer la carte maritime' : 'Afficher la carte maritime'}
{showMap ? t('searchCard.hideMap') : t('searchCard.showMap')}
</Button>
</div>
@ -364,7 +260,7 @@ export default function TrackTracePage() {
</CardContent>
</Card>
{/* Vessel Position Map - Large immersive display */}
{/* Vessel Position Map */}
{showMap && (
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}>
@ -375,12 +271,11 @@ export default function TrackTracePage() {
<Globe className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold">Carte Maritime Mondiale</h3>
<p className="text-blue-100 text-sm">Position des navires en temps réel</p>
<h3 className="text-lg font-semibold">{t('map.title')}</h3>
<p className="text-blue-100 text-sm">{t('map.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Fullscreen Toggle */}
<Button
variant="ghost"
size="sm"
@ -390,16 +285,15 @@ export default function TrackTracePage() {
{isMapFullscreen ? (
<>
<Minimize2 className="h-4 w-4 mr-2" />
Réduire
{t('map.minimize')}
</>
) : (
<>
<Maximize2 className="h-4 w-4 mr-2" />
Plein écran
{t('map.fullscreen')}
</>
)}
</Button>
{/* Close Button */}
<Button
variant="ghost"
size="sm"
@ -416,7 +310,6 @@ export default function TrackTracePage() {
{/* Map Container */}
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
{/* Loading State */}
{isMapLoading && (
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
<div className="text-center">
@ -430,17 +323,16 @@ export default function TrackTracePage() {
</div>
</div>
</div>
<p className="mt-4 text-blue-700 font-medium">Chargement de la carte...</p>
<p className="text-blue-500 text-sm">Connexion à MarineTraffic</p>
<p className="mt-4 text-blue-700 font-medium">{t('map.loading')}</p>
<p className="text-blue-500 text-sm">{t('map.connecting')}</p>
</div>
</div>
)}
{/* MarineTraffic Map */}
<iframe
src="https://www.marinetraffic.com/en/ais/embed/zoom:3/centery:25/centerx:0/maptype:4/shownames:true/mmsi:0/shipid:0/fleet:/fleet_id:/vtypes:/showmenu:true/remember:false"
className="w-full h-full border-0"
title="Carte maritime en temps réel"
title={t('map.iframeTitle')}
loading="lazy"
onLoad={() => setIsMapLoading(false)}
/>
@ -449,20 +341,20 @@ export default function TrackTracePage() {
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}>
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
<Anchor className="h-4 w-4 text-blue-600" />
Légende
{t('map.legend')}
</h4>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-gray-600">Cargos</span>
<span className="text-gray-600">{t('map.cargo')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-gray-600">Tankers</span>
<span className="text-gray-600">{t('map.tankers')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-gray-600">Passagers</span>
<span className="text-gray-600">{t('map.passengers')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
@ -476,12 +368,12 @@ export default function TrackTracePage() {
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">90K+</p>
<p className="text-gray-500 text-xs">Navires actifs</p>
<p className="text-gray-500 text-xs">{t('map.activeVessels')}</p>
</div>
<div className="w-px h-10 bg-gray-200" />
<div className="text-center">
<p className="text-2xl font-bold text-green-600">3,500+</p>
<p className="text-gray-500 text-xs">Ports mondiaux</p>
<p className="text-gray-500 text-xs">{t('map.worldPorts')}</p>
</div>
</div>
</div>
@ -491,7 +383,7 @@ export default function TrackTracePage() {
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<p className="text-xs text-gray-500 flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
Données fournies par MarineTraffic - Mise à jour en temps réel
{t('map.dataSource')}
</p>
<a
href="https://www.marinetraffic.com"
@ -499,7 +391,7 @@ export default function TrackTracePage() {
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
>
Ouvrir sur MarineTraffic
{t('map.openOnSite')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
@ -513,7 +405,7 @@ export default function TrackTracePage() {
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<History className="h-5 w-5 text-gray-600" />
Historique des recherches
{t('history.title')}
</CardTitle>
{searchHistory.length > 0 && (
<Button
@ -522,7 +414,7 @@ export default function TrackTracePage() {
onClick={handleClearHistory}
className="text-gray-500 hover:text-red-600 text-xs"
>
Effacer tout
{t('history.clearAll')}
</Button>
)}
</div>
@ -531,8 +423,8 @@ export default function TrackTracePage() {
{searchHistory.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" />
<p className="text-sm">Aucune recherche récente</p>
<p className="text-xs text-gray-400 mt-1">Vos recherches apparaîtront ici</p>
<p className="text-sm">{t('history.empty')}</p>
<p className="text-xs text-gray-400 mt-1">{t('history.emptyHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
@ -579,14 +471,11 @@ export default function TrackTracePage() {
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
Numéro de conteneur
{t('help.containerNumber.title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
Format standard: 4 lettres + 7 chiffres (ex: MSKU1234567).
Le préfixe indique généralement le propriétaire du conteneur.
</p>
<p className="text-sm text-gray-600">{t('help.containerNumber.description')}</p>
</CardContent>
</Card>
@ -594,14 +483,11 @@ export default function TrackTracePage() {
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-600" />
Connaissement (B/L)
{t('help.billOfLading.title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
Le numéro de connaissement (Bill of Lading) est fourni par le transporteur lors de la confirmation de réservation.
Le format varie selon le transporteur.
</p>
<p className="text-sm text-gray-600">{t('help.billOfLading.description')}</p>
</CardContent>
</Card>
@ -609,13 +495,11 @@ export default function TrackTracePage() {
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-blue-600" />
Référence de réservation
{t('help.bookingRef.title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
Numéro de réservation attribué par le transporteur lors de la réservation initiale de l&apos;espace sur le navire.
</p>
<p className="text-sm text-gray-600">{t('help.bookingRef.description')}</p>
</CardContent>
</Card>
</div>
@ -625,11 +509,8 @@ export default function TrackTracePage() {
<div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
<p className="text-sm text-blue-700 mt-1">
Cette fonctionnalité vous redirige vers le site officiel du transporteur pour obtenir les informations
de suivi les plus récentes. Les données affichées proviennent directement du système du transporteur.
</p>
<p className="text-sm font-medium text-blue-800">{t('infoBox.title')}</p>
<p className="text-sm text-blue-700 mt-1">{t('infoBox.description')}</p>
</div>
</div>
</div>

View File

@ -0,0 +1,107 @@
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

@ -0,0 +1,100 @@
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

@ -0,0 +1,102 @@
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

@ -0,0 +1,89 @@
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

@ -0,0 +1,94 @@
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

@ -0,0 +1,107 @@
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

@ -0,0 +1,112 @@
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

@ -0,0 +1,117 @@
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

@ -0,0 +1,136 @@
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

@ -0,0 +1,102 @@
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

@ -0,0 +1,123 @@
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>
);
}

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