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

This commit is contained in:
David 2026-04-21 18:04:02 +02:00
parent 8649b8a13c
commit ec0173483a
159 changed files with 20128 additions and 14581 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -265,7 +265,9 @@ export class AuthService {
} }
if (resetToken.expiresAt < new Date()) { 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); const user = await this.userRepository.findById(resetToken.userId);
@ -286,10 +288,7 @@ export class AuthService {
await this.userRepository.save(user); await this.userRepository.save(user);
// Mark token as used // Mark token as used
await this.passwordResetTokenRepository.update( await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
{ id: resetToken.id },
{ usedAt: new Date() }
);
this.logger.log(`Password reset successfully for user: ${user.email}`); this.logger.log(`Password reset successfully for user: ${user.email}`);
} }

View File

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

View File

@ -289,7 +289,9 @@ export class AuthController {
}); });
} catch (error) { } catch (error) {
this.logger.error(`Failed to send contact email: ${error}`); this.logger.error(`Failed to send contact email: ${error}`);
throw new InternalServerErrorException("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.' }; return { message: 'Message envoyé avec succès.' };

View File

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

View File

@ -1,4 +1,5 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Locale } from '@domain/value-objects/locale.vo';
/** /**
* User payload interface extracted from JWT * User payload interface extracted from JWT
@ -10,6 +11,7 @@ export interface UserPayload {
organizationId: string; organizationId: string;
firstName: string; firstName: string;
lastName: 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 { import { Controller, Get, Query, Res, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
Controller,
Get,
Query,
Res,
UseGuards,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -22,7 +14,7 @@ export class LogsController {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>( this.logExporterUrl = this.configService.get<string>(
'LOG_EXPORTER_URL', 'LOG_EXPORTER_URL',
'http://xpeditis-log-exporter:3200', 'http://xpeditis-log-exporter:3200'
); );
} }
@ -39,10 +31,7 @@ export class LogsController {
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`); if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
return res.json(); return res.json();
} catch (err: any) { } catch (err: any) {
throw new HttpException( throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
{ error: err.message },
HttpStatus.BAD_GATEWAY,
);
} }
} }
@ -59,7 +48,7 @@ export class LogsController {
@Query('end') end: string, @Query('end') end: string,
@Query('limit') limit: string, @Query('limit') limit: string,
@Query('format') format: string = 'json', @Query('format') format: string = 'json',
@Res() res: Response, @Res() res: Response
) { ) {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -71,10 +60,9 @@ export class LogsController {
if (limit) params.set('limit', limit); if (limit) params.set('limit', limit);
params.set('format', format); params.set('format', format);
const upstream = await fetch( const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, {
`${this.logExporterUrl}/api/logs/export?${params}`, signal: AbortSignal.timeout(30000),
{ signal: AbortSignal.timeout(30000) }, });
);
if (!upstream.ok) { if (!upstream.ok) {
const body = await upstream.json().catch(() => ({})); const body = await upstream.json().catch(() => ({}));

View File

@ -374,18 +374,20 @@ export class CsvBookingService {
booking.markBankTransferDeclared(); booking.markBankTransferDeclared();
const updatedBooking = await this.csvBookingRepository.update(booking); 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 // Send email to all ADMIN users
try { try {
const allUsers = await this.userRepository.findAll(); const allUsers = await this.userRepository.findAll();
const adminEmails = allUsers const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email);
.filter(u => u.role === 'ADMIN' && u.isActive)
.map(u => u.email);
if (adminEmails.length > 0) { if (adminEmails.length > 0) {
const commissionAmount = booking.commissionAmountEur const commissionAmount = booking.commissionAmountEur
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur) ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
booking.commissionAmountEur
)
: 'N/A'; : 'N/A';
await this.emailAdapter.send({ await this.emailAdapter.send({
@ -488,7 +490,9 @@ export class CsvBookingService {
notes: booking.notes, 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, confirmationToken: booking.confirmationToken,
notes: booking.notes, 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) { } catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
} }

View File

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

View File

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

View File

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

View File

@ -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 * All domain exceptions for the Xpeditis platform
*/ */
export * from './domain.exception';
export * from './invalid-port-code.exception'; export * from './invalid-port-code.exception';
export * from './invalid-rate-quote.exception'; export * from './invalid-rate-quote.exception';
export * from './carrier-timeout.exception'; export * from './carrier-timeout.exception';

View File

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

View File

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

View File

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

View File

@ -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({ @Column({
name: 'status', name: 'status',
type: 'enum', 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', default: 'PENDING_PAYMENT',
}) })
@Index() @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' }) @Column({ name: 'documents', type: 'jsonb' })
documents: Array<{ documents: Array<{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useRef } from 'react'; 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 { motion, useInView } from 'framer-motion';
import { import {
Ship, Ship,
@ -13,10 +14,42 @@ import {
Linkedin, Linkedin,
Calendar, Calendar,
ArrowRight, ArrowRight,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type ValueKey = 'excellence' | 'transparency' | 'collaboration' | 'innovation';
type TeamKey = 'ceo' | 'cto' | 'coo' | 'vpSales' | 'vpEng' | 'vpProduct';
type TimelineKey = '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() { export default function AboutPage() {
const t = useTranslations('marketing.about');
const heroRef = useRef(null); const heroRef = useRef(null);
const missionRef = useRef(null); const missionRef = useRef(null);
const valuesRef = useRef(null); const valuesRef = useRef(null);
@ -31,117 +64,6 @@ export default function AboutPage() {
const isTimelineInView = useInView(timelineRef, { once: true }); const isTimelineInView = useInView(timelineRef, { once: true });
const isStatsInView = useInView(statsRef, { once: true }); const isStatsInView = useInView(statsRef, { once: true });
const values = [
{
icon: Target,
title: 'Excellence',
description:
'Nous visons l\'excellence dans chaque aspect de notre plateforme, en offrant une expérience utilisateur de premier ordre.',
color: 'from-blue-500 to-cyan-500',
},
{
icon: Heart,
title: 'Transparence',
description:
'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.',
color: 'from-pink-500 to-rose-500',
},
{
icon: Users,
title: 'Collaboration',
description:
'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.',
color: 'from-purple-500 to-indigo-500',
},
{
icon: TrendingUp,
title: 'Innovation',
description:
'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.',
color: 'from-orange-500 to-amber-500',
},
];
const team = [
{
name: 'Jean-Pierre Durand',
role: 'CEO & Co-fondateur',
bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping',
image: '/assets/images/team/ceo.jpg',
linkedin: '#',
},
{
name: 'Marie Lefebvre',
role: 'CTO & Co-fondatrice',
bio: 'Ex-Google, experte en plateformes B2B et systèmes distribués',
image: '/assets/images/team/cto.jpg',
linkedin: '#',
},
{
name: 'Thomas Martin',
role: 'COO',
bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales',
image: '/assets/images/team/coo.jpg',
linkedin: '#',
},
{
name: 'Sophie Bernard',
role: 'VP Sales',
bio: '15 ans d\'expérience commerciale dans le secteur logistique',
image: '/assets/images/team/vp-sales.jpg',
linkedin: '#',
},
{
name: 'Alexandre Petit',
role: 'VP Engineering',
bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel',
image: '/assets/images/team/vp-eng.jpg',
linkedin: '#',
},
{
name: 'Claire Moreau',
role: 'VP Product',
bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit',
image: '/assets/images/team/vp-product.jpg',
linkedin: '#',
},
];
const timeline = [
{
year: '2021',
title: 'Fondation',
description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.',
},
{
year: '2022',
title: 'Première version',
description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.',
},
{
year: '2023',
title: 'Série A',
description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.',
},
{
year: '2024',
title: 'Expansion',
description: '50+ compagnies maritimes, présence dans 15 pays européens.',
},
{
year: '2025',
title: 'Leader européen',
description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.',
},
];
const stats = [
{ value: '500+', label: 'Clients actifs' },
{ value: '50+', label: 'Compagnies maritimes' },
{ value: '15', label: 'Pays couverts' },
{ value: '100K+', label: 'Réservations/an' },
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0, y: 50 }, hidden: { opacity: 0, y: 50 },
visible: { visible: {
@ -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" className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
> >
<Ship className="w-5 h-5 text-brand-turquoise" /> <Ship className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Notre histoire</span> <span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Révolutionner le fret maritime, {t('title1')}
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
une réservation à la fois {t('title2')}
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Fondée en 2021, Xpeditis est née d'une vision simple : rendre le fret maritime aussi simple {t('intro')}
qu'une réservation de vol. Nous connectons les transitaires du monde entier avec les plus
grandes compagnies maritimes.
</p> </p>
</motion.div> </motion.div>
</div> </div>
@ -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"> <div className="w-16 h-16 bg-brand-turquoise rounded-2xl flex items-center justify-center mb-6">
<Target className="w-8 h-8 text-white" /> <Target className="w-8 h-8 text-white" />
</div> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">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"> <p className="text-gray-600 text-lg leading-relaxed">
Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe {t('mission.body')}
qui simplifie la recherche, la comparaison et la réservation de transport maritime pour
tous les professionnels de la logistique.
</p> </p>
</motion.div> </motion.div>
@ -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"> <div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6">
<Eye className="w-8 h-8 text-white" /> <Eye className="w-8 h-8 text-white" />
</div> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">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"> <p className="text-gray-600 text-lg leading-relaxed">
Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire {t('vision.body')}
à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité
que mérite le commerce international.
</p> </p>
</motion.div> </motion.div>
</motion.div> </motion.div>
@ -269,9 +185,9 @@ export default function AboutPage() {
className="max-w-7xl mx-auto px-6 lg:px-8" className="max-w-7xl mx-auto px-6 lg:px-8"
> >
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => ( {STATS.map((stat, index) => (
<motion.div <motion.div
key={index} key={stat.key}
variants={itemVariants} variants={itemVariants}
className="text-center" className="text-center"
> >
@ -283,7 +199,7 @@ export default function AboutPage() {
> >
{stat.value} {stat.value}
</motion.div> </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> </motion.div>
))} ))}
</div> </div>
@ -299,9 +215,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">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"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
Les principes qui guident chacune de nos décisions {t('valuesSubtitle')}
</p> </p>
</motion.div> </motion.div>
@ -311,11 +227,11 @@ export default function AboutPage() {
animate={isValuesInView ? 'visible' : 'hidden'} animate={isValuesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
> >
{values.map((value, index) => { {VALUES.map((value) => {
const IconComponent = value.icon; const IconComponent = value.icon;
return ( return (
<motion.div <motion.div
key={index} key={value.key}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -10 }} whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all" className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -325,8 +241,8 @@ export default function AboutPage() {
> >
<IconComponent className="w-7 h-7 text-white" /> <IconComponent className="w-7 h-7 text-white" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
<p className="text-gray-600">{value.description}</p> <p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
</motion.div> </motion.div>
); );
})} })}
@ -343,14 +259,13 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">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"> <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> </p>
</motion.div> </motion.div>
<div className="relative"> <div className="relative">
{/* Timeline vertical rail + animated fill */}
<div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full bg-brand-turquoise/15 overflow-hidden"> <div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full bg-brand-turquoise/15 overflow-hidden">
<motion.div <motion.div
initial={{ scaleY: 0 }} initial={{ scaleY: 0 }}
@ -362,9 +277,9 @@ export default function AboutPage() {
</div> </div>
<div className="space-y-12"> <div className="space-y-12">
{timeline.map((item, index) => ( {TIMELINE_YEARS.map((year, index) => (
<motion.div <motion.div
key={index} key={year}
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }} initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, amount: 0.4 }} viewport={{ once: true, amount: 0.4 }}
@ -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="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}> <div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
<Calendar className="w-5 h-5 text-brand-turquoise" /> <Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{item.year}</span> <span className="text-2xl font-bold text-brand-turquoise">{year}</span>
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
<p className="text-gray-600">{item.description}</p> <p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
</div> </div>
</div> </div>
{/* Animated center dot */}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0"> <div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
@ -410,9 +324,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">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"> <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> </p>
</motion.div> </motion.div>
@ -422,9 +336,9 @@ export default function AboutPage() {
animate={isTeamInView ? 'visible' : 'hidden'} animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{team.map((member, index) => ( {TEAM.map((member) => (
<motion.div <motion.div
key={index} key={member.key}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -10 }} whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group" className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
@ -444,8 +358,8 @@ export default function AboutPage() {
</div> </div>
<div className="p-6"> <div className="p-6">
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3> <h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
<p className="text-brand-turquoise font-medium mb-3">{member.role}</p> <p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
<p className="text-gray-600 text-sm">{member.bio}</p> <p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -463,25 +377,24 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Rejoignez l'aventure Xpeditis {t('cta.title')}
</h2> </h2>
<p className="text-xl text-white/80 mb-10"> <p className="text-xl text-white/80 mb-10">
Que vous soyez transitaire à la recherche d'une solution moderne ou talent souhaitant {t('cta.body')}
rejoindre une équipe passionnée, nous avons hâte de vous rencontrer.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<Link <Link
href="/register" href="/register"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2" className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
> >
<span>Créer un compte</span> <span>{t('cta.createAccount')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
<Link <Link
href="/careers" href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg" className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
> >
Voir les offres d'emploi {t('cta.viewCareers')}
</Link> </Link>
</div> </div>
</motion.div> </motion.div>

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; 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 { motion, useInView, AnimatePresence } from 'framer-motion';
import { import {
Briefcase, Briefcase,
@ -22,12 +23,63 @@ import {
LineChart, LineChart,
Headphones, Headphones,
Megaphone, Megaphone,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type BenefitKey = 'health' | 'remote' | 'wellbeing' | 'learning' | 'international' | 'stockOptions';
type StatKey = 'employees' | 'nationalities' | 'offices' | 'womenTech';
type CultureKey = 'item1' | 'item2' | 'item3' | 'item4';
type JobKey = 'frontend' | 'backend' | 'pm' | 'ae' | 'csm' | 'data';
type DepartmentValue = 'all' | 'Engineering' | 'Product' | 'Sales' | 'Customer Success' | 'Data';
type LocationValue = 'all' | 'Paris' | 'Rotterdam' | 'Hambourg';
const BENEFITS: { key: BenefitKey; icon: LucideIcon }[] = [
{ key: 'health', icon: Heart },
{ key: 'remote', icon: Plane },
{ key: 'wellbeing', icon: Coffee },
{ key: 'learning', icon: GraduationCap },
{ key: 'international', icon: Users },
{ key: 'stockOptions', icon: Zap },
];
const STATS: { key: StatKey; value: string }[] = [
{ key: 'employees', value: '50+' },
{ key: 'nationalities', value: '15' },
{ key: 'offices', value: '3' },
{ key: 'womenTech', value: '40%' },
];
const CULTURE_ITEMS: CultureKey[] = ['item1', 'item2', 'item3', 'item4'];
type JobRecord = {
id: number;
key: JobKey;
department: Exclude<DepartmentValue, 'all'>;
location: Exclude<LocationValue, 'all'>;
type: string;
remote: boolean;
salary: string;
icon: LucideIcon;
};
const JOBS: JobRecord[] = [
{ id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code },
{ id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code },
{ id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart },
{ id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone },
{ id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones },
{ id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart },
];
const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data'];
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
export default function CareersPage() { export default function CareersPage() {
const [selectedDepartment, setSelectedDepartment] = useState('all'); const t = useTranslations('marketing.careers');
const [selectedLocation, setSelectedLocation] = useState('all'); const [selectedDepartment, setSelectedDepartment] = useState<DepartmentValue>('all');
const [selectedLocation, setSelectedLocation] = useState<LocationValue>('all');
const [expandedJob, setExpandedJob] = useState<number | null>(null); const [expandedJob, setExpandedJob] = useState<number | null>(null);
const heroRef = useRef(null); const heroRef = useRef(null);
@ -40,161 +92,7 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { once: true }); const isJobsInView = useInView(jobsRef, { once: true });
const isCultureInView = useInView(cultureRef, { once: true }); const isCultureInView = useInView(cultureRef, { once: true });
const benefits = [ const filteredJobs = JOBS.filter((job) => {
{
icon: Heart,
title: 'Mutuelle Premium',
description: 'Couverture santé complète pour vous et votre famille',
},
{
icon: Plane,
title: 'Télétravail Flexible',
description: 'Travaillez d\'où vous voulez, jusqu\'à 3 jours par semaine',
},
{
icon: Coffee,
title: 'Bien-être au Travail',
description: 'Salle de sport, fruits frais, et événements d\'équipe',
},
{
icon: GraduationCap,
title: 'Formation Continue',
description: '2 000€/an de budget formation et conférences',
},
{
icon: Users,
title: 'Équipe Internationale',
description: 'Travaillez avec des talents de 15 nationalités',
},
{
icon: Zap,
title: 'Stock Options',
description: 'Participez à la croissance de l\'entreprise',
},
];
const jobs = [
{
id: 1,
title: 'Senior Frontend Engineer',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '65K - 85K €',
description: 'Rejoignez notre équipe frontend pour développer la prochaine génération de notre plateforme.',
requirements: [
'5+ ans d\'expérience en développement frontend',
'Maîtrise de React, TypeScript et Next.js',
'Expérience avec les design systems',
'Capacité à mentorer des développeurs juniors',
],
icon: Code,
},
{
id: 2,
title: 'Backend Engineer (Node.js)',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '55K - 75K €',
description: 'Construisez des APIs scalables pour connecter les transitaires aux compagnies maritimes.',
requirements: [
'3+ ans d\'expérience en Node.js/NestJS',
'Maîtrise de PostgreSQL et Redis',
'Connaissance des architectures microservices',
'Expérience avec Docker et Kubernetes appréciée',
],
icon: Code,
},
{
id: 3,
title: 'Product Manager',
department: 'Product',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '60K - 80K €',
description: 'Définissez la vision produit et priorisez les fonctionnalités avec notre équipe.',
requirements: [
'4+ ans d\'expérience en product management B2B',
'Expérience dans la logistique ou le shipping appréciée',
'Capacité à analyser les données et définir les KPIs',
'Excellentes compétences en communication',
],
icon: LineChart,
},
{
id: 4,
title: 'Account Executive',
department: 'Sales',
location: 'Rotterdam',
type: 'CDI',
remote: false,
salary: '50K - 70K € + variable',
description: 'Développez notre portefeuille clients aux Pays-Bas et en Belgique.',
requirements: [
'3+ ans d\'expérience en vente B2B',
'Connaissance du secteur maritime/logistique',
'Maîtrise du néerlandais et de l\'anglais',
'Capacité à gérer des cycles de vente longs',
],
icon: Megaphone,
},
{
id: 5,
title: 'Customer Success Manager',
department: 'Customer Success',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '45K - 60K €',
description: 'Accompagnez nos clients dans l\'utilisation de la plateforme et maximisez leur satisfaction.',
requirements: [
'2+ ans d\'expérience en customer success',
'Expérience avec les outils CRM (HubSpot, Salesforce)',
'Excellent relationnel et sens du service',
'Capacité à former et accompagner les utilisateurs',
],
icon: Headphones,
},
{
id: 6,
title: 'Data Analyst',
department: 'Data',
location: 'Hambourg',
type: 'CDI',
remote: true,
salary: '50K - 65K €',
description: 'Analysez les données de shipping pour optimiser notre plateforme et nos processus.',
requirements: [
'3+ ans d\'expérience en data analysis',
'Maîtrise de SQL, Python et des outils BI',
'Expérience avec le shipping/logistics appréciée',
'Capacité à communiquer les insights aux équipes',
],
icon: LineChart,
},
];
const departments = [
{ value: 'all', label: 'Tous les départements' },
{ value: 'Engineering', label: 'Engineering' },
{ value: 'Product', label: 'Product' },
{ value: 'Sales', label: 'Sales' },
{ value: 'Customer Success', label: 'Customer Success' },
{ value: 'Data', label: 'Data' },
];
const locations = [
{ value: 'all', label: 'Toutes les villes' },
{ value: 'Paris', label: 'Paris' },
{ value: 'Rotterdam', label: 'Rotterdam' },
{ value: 'Hambourg', label: 'Hambourg' },
];
const filteredJobs = jobs.filter((job) => {
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment; const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation; const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
return departmentMatch && locationMatch; return departmentMatch && locationMatch;
@ -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" className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
> >
<Briefcase className="w-5 h-5 text-brand-turquoise" /> <Briefcase className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Rejoignez-nous</span> <span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Construisons ensemble {t('title1')}
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
le futur du maritime {t('title2')}
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Rejoignez une équipe passionnée qui révolutionne le fret maritime. Des défis stimulants, {t('intro')}
une culture bienveillante et des opportunités de croissance uniques vous attendent.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
@ -267,14 +164,14 @@ export default function CareersPage() {
href="#jobs" href="#jobs"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2" className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
> >
<span>Voir les offres</span> <span>{t('viewJobs')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</a> </a>
<Link <Link
href="/about" href="/about"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg" className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
> >
En savoir plus {t('learnMore')}
</Link> </Link>
</div> </div>
</motion.div> </motion.div>
@ -295,14 +192,9 @@ export default function CareersPage() {
<section className="py-16 bg-gray-50"> <section className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{[ {STATS.map((stat, index) => (
{ value: '50+', label: 'Employés' },
{ value: '15', label: 'Nationalités' },
{ value: '3', label: 'Bureaux en Europe' },
{ value: '40%', label: 'Femmes dans la tech' },
].map((stat, index) => (
<motion.div <motion.div
key={index} key={stat.key}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
@ -310,7 +202,7 @@ export default function CareersPage() {
className="text-center" className="text-center"
> >
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div> <div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
<div className="text-gray-600 font-medium">{stat.label}</div> <div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div> </motion.div>
))} ))}
</div> </div>
@ -327,10 +219,10 @@ export default function CareersPage() {
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Pourquoi nous rejoindre ? {t('benefitsTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
Nous investissons dans le bien-être et le développement de nos équipes {t('benefitsSubtitle')}
</p> </p>
</motion.div> </motion.div>
@ -340,11 +232,11 @@ export default function CareersPage() {
animate={isBenefitsInView ? 'visible' : 'hidden'} animate={isBenefitsInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{benefits.map((benefit, index) => { {BENEFITS.map((benefit) => {
const IconComponent = benefit.icon; const IconComponent = benefit.icon;
return ( return (
<motion.div <motion.div
key={index} key={benefit.key}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -5 }} whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all" className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -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"> <div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-7 h-7 text-brand-turquoise" /> <IconComponent className="w-7 h-7 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{benefit.title}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
<p className="text-gray-600">{benefit.description}</p> <p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
</motion.div> </motion.div>
); );
})} })}
@ -371,21 +263,15 @@ export default function CareersPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Notre culture {t('cultureTitle')}
</h2> </h2>
<p className="text-xl text-white/80 mb-8"> <p className="text-xl text-white/80 mb-8">
Chez Xpeditis, nous croyons que les meilleures idées viennent d'équipes diverses {t('cultureBody')}
et inclusives. Nous valorisons l'autonomie, la créativité et le feedback constructif.
</p> </p>
<ul className="space-y-4"> <ul className="space-y-4">
{[ {CULTURE_ITEMS.map((itemKey, index) => (
'Transparence totale sur les décisions et les résultats',
'Feedback continu et culture de l\'amélioration',
'Équilibre vie pro/perso respecté',
'Célébration des succès collectifs',
].map((item, index) => (
<motion.li <motion.li
key={index} key={itemKey}
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={isCultureInView ? { opacity: 1, x: 0 } : {}} animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
@ -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"> <div className="w-6 h-6 bg-brand-turquoise rounded-full flex items-center justify-center flex-shrink-0">
<ChevronRight className="w-4 h-4 text-white" /> <ChevronRight className="w-4 h-4 text-white" />
</div> </div>
<span>{item}</span> <span>{t(`culture.${itemKey}`)}</span>
</motion.li> </motion.li>
))} ))}
</ul> </ul>
@ -429,10 +315,10 @@ export default function CareersPage() {
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Nos offres d'emploi {t('jobsTitle')}
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
Trouvez le poste qui correspond à vos ambitions {t('jobsSubtitle')}
</p> </p>
</motion.div> </motion.div>
@ -446,12 +332,12 @@ export default function CareersPage() {
<div className="relative"> <div className="relative">
<select <select
value={selectedDepartment} 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" 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) => ( {DEPARTMENT_VALUES.map((value) => (
<option key={dept.value} value={dept.value}> <option key={value} value={value}>
{dept.label} {value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
</option> </option>
))} ))}
</select> </select>
@ -460,12 +346,12 @@ export default function CareersPage() {
<div className="relative"> <div className="relative">
<select <select
value={selectedLocation} 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" 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) => ( {LOCATION_VALUES.map((value) => (
<option key={loc.value} value={loc.value}> <option key={value} value={value}>
{loc.label} {value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
</option> </option>
))} ))}
</select> </select>
@ -483,8 +369,8 @@ export default function CareersPage() {
{filteredJobs.length === 0 ? ( {filteredJobs.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">Aucune offre trouvée</h3> <h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3>
<p className="text-gray-500">Essayez de modifier vos filtres</p> <p className="text-gray-500">{t('noJobs.body')}</p>
</div> </div>
) : ( ) : (
filteredJobs.map((job) => { filteredJobs.map((job) => {
@ -507,15 +393,15 @@ export default function CareersPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" /> <IconComponent className="w-6 h-6 text-brand-turquoise" />
</div> </div>
<div> <div>
<h3 className="text-xl font-bold text-brand-navy">{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"> <div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
<span>{job.department}</span> <span>{t(`departments.${job.department}` as any)}</span>
</span> </span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
<span>{job.location}</span> <span>{t(`locations.${job.location}` as any)}</span>
</span> </span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
@ -528,7 +414,7 @@ export default function CareersPage() {
<div className="hidden md:flex items-center space-x-2"> <div className="hidden md:flex items-center space-x-2">
{job.remote && ( {job.remote && (
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full"> <span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
Remote OK {t('jobCard.remote')}
</span> </span>
)} )}
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full"> <span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
@ -554,13 +440,13 @@ export default function CareersPage() {
className="border-t border-gray-100" className="border-t border-gray-100"
> >
<div className="p-6 bg-gray-50"> <div className="p-6 bg-gray-50">
<p className="text-gray-600 mb-6">{job.description}</p> <p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
<h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4> <h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4>
<ul className="space-y-2 mb-6"> <ul className="space-y-2 mb-6">
{job.requirements.map((req, index) => ( {JOB_REQ_KEYS.map((reqKey) => (
<li key={index} className="flex items-start space-x-2 text-gray-600"> <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" /> <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> </li>
))} ))}
</ul> </ul>
@ -569,11 +455,11 @@ export default function CareersPage() {
href={`/careers/${job.id}`} href={`/careers/${job.id}`}
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-medium flex items-center space-x-2" className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-medium flex items-center space-x-2"
> >
<span>Postuler</span> <span>{t('jobCard.apply')}</span>
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
<button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700"> <button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700">
En savoir plus {t('jobCard.learnMore')}
</button> </button>
</div> </div>
</div> </div>
@ -598,17 +484,16 @@ export default function CareersPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl font-bold text-brand-navy mb-6"> <h2 className="text-4xl font-bold text-brand-navy mb-6">
Pas de poste correspondant ? {t('cta.title')}
</h2> </h2>
<p className="text-xl text-gray-600 mb-10"> <p className="text-xl text-gray-600 mb-10">
Envoyez-nous une candidature spontanée ! Nous sommes toujours à la recherche de {t('cta.body')}
talents passionnés pour rejoindre notre aventure.
</p> </p>
<Link <Link
href="/contact" href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg" className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
> >
<span>Candidature spontanée</span> <span>{t('cta.spontaneous')}</span>
<ArrowRight className="w-5 h-5" /> <ArrowRight className="w-5 h-5" />
</Link> </Link>
</motion.div> </motion.div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin'; import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations'; import { createOrganization, updateOrganization } from '@/lib/api/organizations';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
@ -30,6 +31,8 @@ interface Organization {
} }
export default function AdminOrganizationsPage() { export default function AdminOrganizationsPage() {
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -86,7 +89,7 @@ export default function AdminOrganizationsPage() {
setOrganizations(response.organizations || []); setOrganizations(response.organizations || []);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to load organizations'); setError(err.message || t('loadError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -95,10 +98,9 @@ export default function AdminOrganizationsPage() {
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
// Transform formData to match API expected format
const apiData = { const apiData = {
name: formData.name, name: formData.name,
type: formData.type as any, // OrganizationType type: formData.type as any,
address_street: formData.address.street, address_street: formData.address.street,
address_city: formData.address.city, address_city: formData.address.city,
address_postal_code: formData.address.postalCode, address_postal_code: formData.address.postalCode,
@ -112,7 +114,7 @@ export default function AdminOrganizationsPage() {
setShowCreateModal(false); setShowCreateModal(false);
resetForm(); resetForm();
} catch (err: any) { } 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); setSelectedOrg(null);
resetForm(); resetForm();
} catch (err: any) { } 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); setVerifyingId(orgId);
const result = await verifySiret(orgId); const result = await verifySiret(orgId);
if (result.verified) { 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(); await fetchOrganizations();
} else { } else {
alert(result.message || 'SIRET invalide ou introuvable.'); alert(result.message || t('siretInvalid'));
} }
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Erreur lors de la verification du SIRET'); alert(err.message || t('siretError'));
} finally { } finally {
setVerifyingId(null); setVerifyingId(null);
} }
}; };
const handleApproveSiret = async (orgId: string) => { const handleApproveSiret = async (orgId: string) => {
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return; if (!confirm(t('confirmApprove'))) return;
try { try {
setVerifyingId(orgId); setVerifyingId(orgId);
const result = await approveSiret(orgId); const result = await approveSiret(orgId);
alert(result.message); alert(result.message);
await fetchOrganizations(); await fetchOrganizations();
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Erreur lors de l\'approbation'); alert(err.message || t('siretApproveError'));
} finally { } finally {
setVerifyingId(null); setVerifyingId(null);
} }
}; };
const handleRejectSiret = async (orgId: string) => { 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 { try {
setVerifyingId(orgId); setVerifyingId(orgId);
const result = await rejectSiret(orgId); const result = await rejectSiret(orgId);
alert(result.message); alert(result.message);
await fetchOrganizations(); await fetchOrganizations();
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Erreur lors du refus'); alert(err.message || t('siretRejectError'));
} finally { } finally {
setVerifyingId(null); setVerifyingId(null);
} }
@ -214,12 +216,20 @@ export default function AdminOrganizationsPage() {
setShowEditModal(true); setShowEditModal(true);
}; };
const getTypeLabel = (type: string) => {
const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER'];
if (allowed.includes(type)) {
return t(`types.${type}` as any);
}
return type.replace('_', ' ');
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading organizations...</p> <p className="mt-4 text-gray-600">{t('loading')}</p>
</div> </div>
</div> </div>
); );
@ -228,14 +238,14 @@ export default function AdminOrganizationsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Organization Management" title={t('title')}
description="Manage all organizations in the system" description={t('subtitle')}
actions={ actions={
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
> >
+ Create Organization {t('create')}
</button> </button>
} }
/> />
@ -259,53 +269,53 @@ export default function AdminOrganizationsPage() {
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' : org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800' 'bg-purple-100 text-purple-800'
}`}> }`}>
{org.type.replace('_', ' ')} {getTypeLabel(org.type)}
</span> </span>
</div> </div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${ <span className={`px-2 py-1 text-xs font-semibold rounded-full ${
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}> }`}>
{org.isActive ? 'Active' : 'Inactive'} {org.isActive ? t('active') : t('inactive')}
</span> </span>
</div> </div>
<div className="space-y-2 text-sm text-gray-600 mb-4"> <div className="space-y-2 text-sm text-gray-600 mb-4">
{org.scac && ( {org.scac && (
<div> <div>
<span className="font-medium">SCAC:</span> {org.scac} <span className="font-medium">{t('scac')}:</span> {org.scac}
</div> </div>
)} )}
{org.siren && ( {org.siren && (
<div> <div>
<span className="font-medium">SIREN:</span> {org.siren} <span className="font-medium">{t('siren')}:</span> {org.siren}
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">SIRET:</span> <span className="font-medium">{t('siret')}:</span>
{org.siret ? ( {org.siret ? (
<> <>
<span>{org.siret}</span> <span>{org.siret}</span>
{org.siretVerified ? ( {org.siretVerified ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800"> <span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Verifie {t('verified')}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800"> <span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Non verifie {t('notVerified')}
</span> </span>
)} )}
</> </>
) : ( ) : (
<span className="text-gray-400">Non renseigne</span> <span className="text-gray-400">{t('notProvided')}</span>
)} )}
</div> </div>
{org.contact_email && ( {org.contact_email && (
<div> <div>
<span className="font-medium">Email:</span> {org.contact_email} <span className="font-medium">{t('email')}:</span> {org.contact_email}
</div> </div>
)} )}
<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>
</div> </div>
@ -315,7 +325,7 @@ export default function AdminOrganizationsPage() {
onClick={() => openEditModal(org)} onClick={() => openEditModal(org)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium" className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
> >
Edit {t('edit')}
</button> </button>
{org.siret && !org.siretVerified && ( {org.siret && !org.siretVerified && (
<button <button
@ -323,7 +333,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id} disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50" className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
> >
{verifyingId === org.id ? '...' : 'Verifier API'} {verifyingId === org.id ? t('verifying') : t('verifyApi')}
</button> </button>
)} )}
</div> </div>
@ -335,7 +345,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id} disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50" className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
> >
Approuver SIRET {t('approveSiret')}
</button> </button>
) : ( ) : (
<button <button
@ -343,7 +353,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id} disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50" className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
> >
Rejeter SIRET {t('rejectSiret')}
</button> </button>
)} )}
</div> </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="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4"> <h2 className="text-xl font-bold mb-4">
{showCreateModal ? 'Create New Organization' : 'Edit Organization'} {showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
</h2> </h2>
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4"> <form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Organization Name *</label> <label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label>
<input <input
type="text" type="text"
required required
@ -374,21 +384,21 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <select
value={formData.type} value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })} onChange={e => setFormData({ ...formData, type: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="FREIGHT_FORWARDER">Freight Forwarder</option> <option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
<option value="CARRIER">Carrier</option> <option value="CARRIER">{t('types.CARRIER')}</option>
<option value="SHIPPER">Shipper</option> <option value="SHIPPER">{t('types.SHIPPER')}</option>
</select> </select>
</div> </div>
{formData.type === 'CARRIER' && ( {formData.type === 'CARRIER' && (
<div> <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 <input
type="text" type="text"
required={formData.type === 'CARRIER'} required={formData.type === 'CARRIER'}
@ -401,7 +411,7 @@ export default function AdminOrganizationsPage() {
)} )}
<div> <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 <input
type="text" type="text"
maxLength={9} maxLength={9}
@ -412,19 +422,19 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="text" type="text"
maxLength={14} maxLength={14}
value={formData.siret} value={formData.siret}
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })} onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder="12345678901234" placeholder={t('modal.siretPlaceholder')}
/> />
</div> </div>
<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 <input
type="text" type="text"
value={formData.eori} value={formData.eori}
@ -434,7 +444,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="tel" type="tel"
value={formData.contact_phone} value={formData.contact_phone}
@ -444,7 +454,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="email" type="email"
value={formData.contact_email} value={formData.contact_email}
@ -454,7 +464,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Street Address *</label> <label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label>
<input <input
type="text" type="text"
required required
@ -468,7 +478,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="text" type="text"
required required
@ -482,7 +492,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="text" type="text"
required required
@ -496,7 +506,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="text" type="text"
value={formData.address.state} value={formData.address.state}
@ -509,7 +519,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<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 <input
type="text" type="text"
required required
@ -524,7 +534,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Logo URL</label> <label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label>
<input <input
type="url" type="url"
value={formData.logoUrl} 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" className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
> >
Cancel {t('modal.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
{showCreateModal ? 'Create' : 'Update'} {showCreateModal ? t('modal.create') : t('modal.update')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin'; import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
import { createUser } from '@/lib/api/users'; import { createUser } from '@/lib/api/users';
import { getAllOrganizations } from '@/lib/api/admin'; import { getAllOrganizations } from '@/lib/api/admin';
@ -25,6 +26,8 @@ interface Organization {
} }
export default function AdminUsersPage() { export default function AdminUsersPage() {
const t = useTranslations('dashboard.admin.users');
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -51,7 +54,6 @@ export default function AdminUsersPage() {
password: '', password: '',
}); });
// Fetch users and organizations
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, []); }, []);
@ -68,7 +70,7 @@ export default function AdminUsersPage() {
setOrganizations(orgsResponse.organizations || []); setOrganizations(orgsResponse.organizations || []);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to load data'); setError(err.message || t('loadError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -82,7 +84,7 @@ export default function AdminUsersPage() {
setShowCreateModal(false); setShowCreateModal(false);
resetForm(); resetForm();
} catch (err: any) { } 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); setSelectedUser(null);
resetForm(); resetForm();
} catch (err: any) { } 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); setShowDeleteConfirm(false);
setSelectedUser(null); setSelectedUser(null);
} catch (err: any) { } 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); setShowDeleteConfirm(true);
}; };
const getRoleLabel = (role: string) => {
const allowed = ['USER', 'MANAGER', 'ADMIN', 'VIEWER'];
if (allowed.includes(role)) {
return t(`roles.${role}` as any);
}
return role;
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading users...</p> <p className="mt-4 text-gray-600">{t('loading')}</p>
</div> </div>
</div> </div>
); );
@ -162,14 +172,14 @@ export default function AdminUsersPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="User Management" title={t('title')}
description="Manage all users in the system" description={t('subtitle')}
actions={ actions={
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
> >
+ Create User {t('create')}
</button> </button>
} }
/> />
@ -187,22 +197,22 @@ export default function AdminUsersPage() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User {t('table.user')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email {t('table.email')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role {t('table.role')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization {t('table.organization')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status {t('table.status')}
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions {t('table.actions')}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -223,7 +233,7 @@ export default function AdminUsersPage() {
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' : user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800' 'bg-gray-100 text-gray-800'
}`}> }`}>
{user.role} {getRoleLabel(user.role)}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -233,7 +243,7 @@ export default function AdminUsersPage() {
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}> }`}>
{user.isActive ? 'Active' : 'Inactive'} {user.isActive ? t('active') : t('inactive')}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
@ -241,13 +251,13 @@ export default function AdminUsersPage() {
onClick={() => openEditModal(user)} onClick={() => openEditModal(user)}
className="text-blue-600 hover:text-blue-900" className="text-blue-600 hover:text-blue-900"
> >
Edit {t('edit')}
</button> </button>
<button <button
onClick={() => openDeleteConfirm(user)} onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900" className="text-red-600 hover:text-red-900"
> >
Delete {t('delete')}
</button> </button>
</td> </td>
</tr> </tr>
@ -260,10 +270,10 @@ export default function AdminUsersPage() {
{showCreateModal && ( {showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Create New User</h2> <h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Email</label> <label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label>
<input <input
type="email" type="email"
required required
@ -273,7 +283,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<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 <input
type="text" type="text"
required required
@ -283,7 +293,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<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 <input
type="text" type="text"
required required
@ -293,27 +303,27 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<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 <select
value={formData.role} value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="USER">User</option> <option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">Manager</option> <option value="MANAGER">{t('roles.MANAGER')}</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">{t('roles.ADMIN')}</option>
<option value="VIEWER">Viewer</option> <option value="VIEWER">{t('roles.VIEWER')}</option>
</select> </select>
</div> </div>
<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 <select
required required
value={formData.organizationId} value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })} onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="">Select Organization</option> <option value="">{t('modal.selectOrganization')}</option>
{organizations.map(org => ( {organizations.map(org => (
<option key={org.id} value={org.id}> <option key={org.id} value={org.id}>
{org.name} {org.name}
@ -323,7 +333,7 @@ export default function AdminUsersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
Password (leave empty for auto-generated) {t('modal.password')}
</label> </label>
<input <input
type="password" 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" className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
> >
Cancel {t('modal.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
Create {t('modal.create')}
</button> </button>
</div> </div>
</form> </form>
@ -359,10 +369,10 @@ export default function AdminUsersPage() {
{showEditModal && selectedUser && ( {showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Edit User</h2> <h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
<form onSubmit={handleUpdate} className="space-y-4"> <form onSubmit={handleUpdate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Email (read-only)</label> <label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label>
<input <input
type="email" type="email"
disabled disabled
@ -371,7 +381,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<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 <input
type="text" type="text"
required required
@ -381,7 +391,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<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 <input
type="text" type="text"
required required
@ -391,16 +401,16 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<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 <select
value={formData.role} value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="USER">User</option> <option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">Manager</option> <option value="MANAGER">{t('roles.MANAGER')}</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">{t('roles.ADMIN')}</option>
<option value="VIEWER">Viewer</option> <option value="VIEWER">{t('roles.VIEWER')}</option>
</select> </select>
</div> </div>
<div className="flex justify-end space-x-2 pt-4"> <div className="flex justify-end space-x-2 pt-4">
@ -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" className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
> >
Cancel {t('modal.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
Update {t('modal.update')}
</button> </button>
</div> </div>
</form> </form>
@ -431,10 +441,9 @@ export default function AdminUsersPage() {
{showDeleteConfirm && selectedUser && ( {showDeleteConfirm && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4 text-red-600">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"> <p className="text-gray-700 mb-6">
Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>? {t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })}
This action cannot be undone.
</p> </p>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button <button
@ -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" className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
> >
Cancel {t('deleteConfirm.cancel')}
</button> </button>
<button <button
onClick={handleDelete} onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700" className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
> >
Delete {t('deleteConfirm.confirm')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,17 +1,15 @@
/**
* Booking Detail Page
*
* Display full booking information
*/
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getBooking } from '@/lib/api'; import { getBooking } from '@/lib/api';
import Link from 'next/link'; import { Link } from '@/i18n/navigation';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
export default function BookingDetailPage() { export default function BookingDetailPage() {
const t = useTranslations('dashboard.bookingDetail');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const params = useParams(); const params = useParams();
const bookingId = params.id as string; const bookingId = params.id as string;
@ -33,10 +31,18 @@ export default function BookingDetailPage() {
return colors[status] || 'bg-gray-100 text-gray-800'; 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 () => { const downloadPDF = async () => {
try { try {
// TODO: Implement PDF download functionality alert(t('pdfNotImplemented'));
alert('PDF download functionality is not yet implemented');
console.log('Download PDF for booking:', bookingId); console.log('Download PDF for booking:', bookingId);
} catch (error) { } catch (error) {
console.error('Failed to download PDF:', error); console.error('Failed to download PDF:', error);
@ -54,12 +60,12 @@ export default function BookingDetailPage() {
if (!booking) { if (!booking) {
return ( return (
<div className="text-center py-12"> <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 <Link
href="/dashboard/bookings" href="/dashboard/bookings"
className="mt-4 inline-block text-blue-600 hover:text-blue-700" className="mt-4 inline-block text-blue-600 hover:text-blue-700"
> >
Back to bookings {t('back')}
</Link> </Link>
</div> </div>
); );
@ -67,14 +73,13 @@ export default function BookingDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Link <Link
href="/dashboard/bookings" href="/dashboard/bookings"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
> >
Back to bookings {t('back')}
</Link> </Link>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1> <h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
@ -83,11 +88,11 @@ export default function BookingDetailPage() {
booking.status booking.status
)}`} )}`}
> >
{booking.status} {getStatusLabel(booking.status)}
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 mt-1"> <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> </p>
</div> </div>
<div className="flex space-x-3"> <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" 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> </svg>
Download PDF {t('downloadPdf')}
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Cargo Details */}
<div className="bg-white rounded-lg shadow p-6"> <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"> <dl className="grid grid-cols-1 gap-4">
<div> <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> <dd className="mt-1 text-sm text-gray-900">{booking.cargoDescription}</dd>
</div> </div>
{booking.specialInstructions && ( {booking.specialInstructions && (
<div> <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> <dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
</div> </div>
)} )}
</dl> </dl>
</div> </div>
{/* Containers */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> <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> </h2>
<div className="space-y-3"> <div className="space-y-3">
{booking.containers?.map((container, index) => ( {booking.containers?.map((container, index) => (
<div key={container.id || index} className="border rounded-lg p-4"> <div key={container.id || index} className="border rounded-lg p-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <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> <p className="text-sm text-gray-900">{container.type}</p>
</div> </div>
{container.containerNumber && ( {container.containerNumber && (
<div> <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> <p className="text-sm text-gray-900">{container.containerNumber}</p>
</div> </div>
)} )}
{container.sealNumber && ( {container.sealNumber && (
<div> <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> <p className="text-sm text-gray-900">{container.sealNumber}</p>
</div> </div>
)} )}
{container.vgm && ( {container.vgm && (
<div> <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> <p className="text-sm text-gray-900">{container.vgm}</p>
</div> </div>
)} )}
@ -165,47 +167,46 @@ export default function BookingDetailPage() {
</div> </div>
</div> </div>
{/* Shipper & Consignee */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-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"> <dl className="space-y-2">
<div> <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> <dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
</div> </div>
<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> <dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
</div> </div>
<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> <dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
</div> </div>
<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> <dd className="text-sm text-gray-900">{booking.shipper.contactPhone}</dd>
</div> </div>
</dl> </dl>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <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"> <dl className="space-y-2">
<div> <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> <dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
</div> </div>
<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> <dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
</div> </div>
<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> <dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
</div> </div>
<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> <dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
</div> </div>
</dl> </dl>
@ -213,11 +214,9 @@ export default function BookingDetailPage() {
</div> </div>
</div> </div>
{/* Sidebar */}
<div className="space-y-6"> <div className="space-y-6">
{/* Timeline */}
<div className="bg-white rounded-lg shadow p-6"> <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"> <div className="flow-root">
<ul className="-mb-8"> <ul className="-mb-8">
<li> <li>
@ -244,9 +243,9 @@ export default function BookingDetailPage() {
</div> </div>
<div className="min-w-0 flex-1 pt-1.5"> <div className="min-w-0 flex-1 pt-1.5">
<div> <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"> <p className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleString()} {new Date(booking.createdAt).toLocaleString(dateLocale)}
</p> </p>
</div> </div>
</div> </div>
@ -257,18 +256,17 @@ export default function BookingDetailPage() {
</div> </div>
</div> </div>
{/* Quick Info */}
<div className="bg-white rounded-lg shadow p-6"> <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"> <dl className="space-y-3">
<div> <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> <dd className="mt-1 text-sm text-gray-900">{booking.id}</dd>
</div> </div>
<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"> <dd className="mt-1 text-sm text-gray-900">
{new Date(booking.updatedAt).toLocaleString()} {new Date(booking.updatedAt).toLocaleString(dateLocale)}
</dd> </dd>
</div> </div>
</dl> </dl>

View File

@ -1,23 +1,21 @@
/**
* Bookings List Page
*
* Display all bookings (standard + CSV) with filters and search
*/
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api'; import { listCsvBookings } from '@/lib/api';
import Link from 'next/link'; import { Link } from '@/i18n/navigation';
import { Plus, Clock } from 'lucide-react'; import { Plus, Clock } from 'lucide-react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { useTranslations, useLocale } from 'next-intl';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() { export default function BookingsListPage() {
const t = useTranslations('dashboard.bookingsList');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route'); const [searchType, setSearchType] = useState<SearchType>('route');
@ -32,29 +30,24 @@ export default function BookingsListPage() {
} }
}, [searchParams]); }, [searchParams]);
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
const { data: csvData, isLoading, error: csvError } = useQuery({ const { data: csvData, isLoading, error: csvError } = useQuery({
queryKey: ['csv-bookings'], queryKey: ['csv-bookings'],
queryFn: () => queryFn: () =>
listCsvBookings({ listCsvBookings({
page: 1, 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); if (csvError) console.error('CSV bookings error:', csvError);
// Filter bookings based on search term, search type, and status
const filterBookings = (bookings: any[]) => { const filterBookings = (bookings: any[]) => {
let filtered = bookings; let filtered = bookings;
// Filter by status first (if status filter is active)
if (statusFilter) { if (statusFilter) {
filtered = filtered.filter((booking: any) => booking.status === statusFilter); filtered = filtered.filter((booking: any) => booking.status === statusFilter);
} }
// Then filter by search term if provided
if (searchTerm.trim()) { if (searchTerm.trim()) {
const term = searchTerm.toLowerCase(); const term = searchTerm.toLowerCase();
@ -71,7 +64,7 @@ export default function BookingsListPage() {
case 'status': case 'status':
return booking.status?.toLowerCase().includes(term); return booking.status?.toLowerCase().includes(term);
case 'date': 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); return date.includes(term);
case 'quote': case 'quote':
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term); return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
@ -84,52 +77,42 @@ export default function BookingsListPage() {
return filtered; return filtered;
}; };
// Get all filtered bookings
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }))); const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
// Calculate pagination
const totalBookings = filteredBookings.length; const totalBookings = filteredBookings.length;
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE); const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
const startIndex = (page - 1) * ITEMS_PER_PAGE; const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE;
const paginatedBookings = filteredBookings.slice(startIndex, endIndex); const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
// Reset page to 1 when filters change
const resetPage = () => setPage(1); const resetPage = () => setPage(1);
const statusOptions = [ const statusOptions = [
{ value: '', label: 'Tous les statuts' }, { value: '', label: t('statusFilter.all') },
{ value: 'PENDING', label: 'En attente' }, { value: 'PENDING', label: t('status.pending') },
{ value: 'ACCEPTED', label: 'Accepté' }, { value: 'ACCEPTED', label: t('status.accepted') },
{ value: 'REJECTED', label: 'Refusé' }, { value: 'REJECTED', label: t('status.rejected') },
]; ];
const searchTypeOptions = [ const searchTypeOptions: { value: SearchType; label: string }[] = [
{ value: 'route', label: 'Route (Origine/Destination)' }, { value: 'route', label: t('searchType.route') },
{ value: 'pallets', label: 'Palettes/Colis' }, { value: 'pallets', label: t('searchType.pallets') },
{ value: 'weight', label: 'Poids (kg)' }, { value: 'weight', label: t('searchType.weight') },
{ value: 'status', label: 'Statut' }, { value: 'status', label: t('searchType.status') },
{ value: 'date', label: 'Date' }, { value: 'date', label: t('searchType.date') },
{ value: 'quote', label: 'N° Devis' }, { value: 'quote', label: t('searchType.quote') },
]; ];
const getPlaceholder = () => { const getPlaceholder = () => {
switch (searchType) { const keyMap: Record<SearchType, string> = {
case 'pallets': route: 'searchPlaceholder.route',
return 'Rechercher par nombre de palettes...'; pallets: 'searchPlaceholder.pallets',
case 'weight': weight: 'searchPlaceholder.weight',
return 'Rechercher par poids en kg...'; status: 'searchPlaceholder.status',
case 'route': date: 'searchPlaceholder.date',
return 'Rechercher par ville (origine ou destination)...'; quote: 'searchPlaceholder.quote',
case 'status': };
return 'Rechercher par statut...'; return t(keyMap[searchType] as any);
case 'date':
return 'Rechercher par date (JJ/MM/AAAA)...';
case 'quote':
return 'Rechercher par numéro de devis...';
default:
return 'Rechercher...';
}
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@ -142,25 +125,24 @@ export default function BookingsListPage() {
}; };
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const labels: Record<string, string> = { const map: Record<string, string> = {
PENDING: 'En attente', PENDING: t('status.pending'),
ACCEPTED: 'Accepté', ACCEPTED: t('status.accepted'),
REJECTED: 'Refusé', REJECTED: t('status.rejected'),
}; };
return labels[status] || status; return map[status] || status;
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Bank transfer declared banner */}
{showTransferBanner && ( {showTransferBanner && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between"> <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"> <div className="flex items-start space-x-3">
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" /> <Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div> <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"> <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> </p>
</div> </div>
</div> </div>
@ -168,30 +150,23 @@ export default function BookingsListPage() {
</div> </div>
)} )}
<PageHeader <PageHeader
title="Réservations" title={t('title')}
description="Gérez et suivez vos envois" description={t('description')}
actions={ actions={
<> <>
<ExportButton <ExportButton
data={filteredBookings} data={filteredBookings}
filename="reservations" filename={t('exportFilename')}
columns={[ columns={[
{ key: 'id', label: 'ID' }, { key: 'id', label: t('export.id') },
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` }, { key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` },
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` }, { key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` }, { key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` },
{ key: 'origin', label: 'Origine' }, { key: 'origin', label: t('export.origin') },
{ key: 'destination', label: 'Destination' }, { key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: 'Transporteur' }, { key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: 'Statut', format: (v) => { { key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
const labels: Record<string, string> = { { key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[v] || v;
}},
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]} ]}
/> />
<Link <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" 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" /> <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> </Link>
</> </>
} }
/> />
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4"> <div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div> <div>
<label htmlFor="searchType" className="sr-only"> <label htmlFor="searchType" className="sr-only">
Type de recherche {t('searchType.label')}
</label> </label>
<select <select
id="searchType" id="searchType"
@ -230,7 +204,7 @@ export default function BookingsListPage() {
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="search" className="sr-only"> <label htmlFor="search" className="sr-only">
Rechercher {t('search')}
</label> </label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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>
<div> <div>
<label htmlFor="status" className="sr-only"> <label htmlFor="status" className="sr-only">
Statut {t('statusFilter.label')}
</label> </label>
<select <select
id="status" id="status"
@ -284,16 +258,14 @@ export default function BookingsListPage() {
</div> </div>
</div> </div>
{/* Bookings List */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="px-6 py-12 text-center text-gray-500"> <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> <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> </div>
) : paginatedBookings && paginatedBookings.length > 0 ? ( ) : paginatedBookings && paginatedBookings.length > 0 ? (
<> <>
{/* Mobile cards */}
<div className="md:hidden divide-y divide-gray-200"> <div className="md:hidden divide-y divide-gray-200">
{paginatedBookings.map((booking: any) => ( {paginatedBookings.map((booking: any) => (
<div key={`${booking.type}-${booking.id}`} className="p-4 space-y-3"> <div key={`${booking.type}-${booking.id}`} className="p-4 space-y-3">
@ -314,64 +286,63 @@ export default function BookingsListPage() {
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs"> <div className="grid grid-cols-3 gap-2 text-xs">
<div> <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"> <div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv' {booking.type === 'csv'
? `${booking.palletCount} pal.` ? t('units.palletsShort', { count: booking.palletCount })
: `${booking.containers?.length || 0} cont.`} : t('units.containersShort', { count: booking.containers?.length || 0 })}
</div> </div>
</div> </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"> <div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv' {booking.type === 'csv'
? `${booking.weightKG} kg` ? t('units.kg', { value: booking.weightKG })
: booking.totalWeight ? `${booking.totalWeight} kg` : 'N/A'} : booking.totalWeight ? t('units.kg', { value: booking.totalWeight }) : 'N/A'}
</div> </div>
</div> </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"> <div className="font-medium text-gray-900 mt-0.5">
{(booking.createdAt || booking.requestedAt) {(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'} : 'N/A'}
</div> </div>
</div> </div>
</div> </div>
<div className="text-xs text-gray-400"> <div className="text-xs text-gray-400">
{booking.type === 'csv' {booking.type === 'csv'
? `Réf: #${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}` ? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() })
: `Booking: ${booking.bookingNumber || '-'}`} : t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Desktop table */}
<div className="hidden md:block overflow-x-auto"> <div className="hidden md:block overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Palettes/Colis {t('columns.palletsPackages')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poids {t('columns.weight')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route {t('columns.route')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut {t('columns.status')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date {t('columns.date')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis {t('columns.quoteNumber')}
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking {t('columns.bookingNumber')}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -381,8 +352,8 @@ export default function BookingsListPage() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{booking.type === 'csv' {booking.type === 'csv'
? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}` ? t('units.palletsCount', { count: booking.palletCount })
: `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`} : t('units.containersCount', { count: booking.containers?.length || 0 })}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'} {booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
@ -391,16 +362,16 @@ export default function BookingsListPage() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{booking.type === 'csv' {booking.type === 'csv'
? `${booking.weightKG} kg` ? t('units.kg', { value: booking.weightKG })
: booking.totalWeight : booking.totalWeight
? `${booking.totalWeight} kg` ? t('units.kg', { value: booking.totalWeight })
: 'N/A'} : 'N/A'}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{booking.type === 'csv' {booking.type === 'csv'
? `${booking.volumeCBM} CBM` ? t('units.cbm', { value: booking.volumeCBM })
: booking.totalVolume : booking.totalVolume
? `${booking.totalVolume} CBM` ? t('units.cbm', { value: booking.totalVolume })
: ''} : ''}
</div> </div>
</td> </td>
@ -427,7 +398,7 @@ export default function BookingsListPage() {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(booking.createdAt || booking.requestedAt) {(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString('fr-FR', { ? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
@ -448,7 +419,6 @@ export default function BookingsListPage() {
</table> </table>
</div> </div>
{/* Pagination */}
{totalPages > 1 && ( {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="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"> <div className="flex-1 flex justify-between sm:hidden">
@ -457,22 +427,25 @@ export default function BookingsListPage() {
disabled={page === 1} 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" 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>
<button <button
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
disabled={page >= totalPages} 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" 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> </button>
</div> </div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '} {t.rich('pagination.showing', {
<span className="font-medium">{Math.min(endIndex, totalBookings)}</span> sur{' '} start: startIndex + 1,
<span className="font-medium">{totalBookings}</span> résultat{totalBookings > 1 ? 's' : ''} end: Math.min(endIndex, totalBookings),
total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>,
})}
</p> </p>
</div> </div>
<div> <div>
@ -482,16 +455,14 @@ export default function BookingsListPage() {
disabled={page === 1} 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" 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"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
</button> </button>
{/* Page numbers */}
{[...Array(totalPages)].map((_, idx) => { {[...Array(totalPages)].map((_, idx) => {
const pageNum = idx + 1; const pageNum = idx + 1;
// Show first page, last page, current page, and pages around current
const showPage = pageNum === 1 || const showPage = pageNum === 1 ||
pageNum === totalPages || pageNum === totalPages ||
(pageNum >= page - 1 && pageNum <= page + 1); (pageNum >= page - 1 && pageNum <= page + 1);
@ -524,7 +495,7 @@ export default function BookingsListPage() {
disabled={page >= totalPages} 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" 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"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg> </svg>
@ -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" 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> </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"> <p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter {searchTerm || statusFilter
? 'Essayez d\'ajuster vos filtres' ? t('empty.hasFilters')
: 'Commencez par créer votre première réservation'} : t('empty.noBookings')}
</p> </p>
<div className="mt-6"> <div className="mt-6">
<Link <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" 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" /> <Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation {t('new')}
</Link> </Link>
</div> </div>
</div> </div>

View File

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

View File

@ -1,16 +1,11 @@
/**
* Dashboard Layout
*
* Layout with sidebar navigation for dashboard pages
*/
'use client'; 'use client';
import { useAuth } from '@/lib/context/auth-context'; import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link'; import { Link, usePathname, useRouter } from '@/i18n/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown'; import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown'; import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image'; import Image from 'next/image';
import { import {
@ -36,6 +31,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const { hasFeature, subscription } = useSubscription(); const { hasFeature, subscription } = useSubscription();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const t = useTranslations('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => { useEffect(() => {
@ -57,16 +53,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
} }
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [ const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' }, { name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package }, { name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
{ name: 'Documents', href: '/dashboard/documents', icon: FileText }, { name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' }, { name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' }, { name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 }, { name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
{ name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature }, { name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ ...(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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden" 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 <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 ${ 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' sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`} }`}
> >
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-6 border-b"> <div className="flex items-center justify-between h-16 px-6 border-b">
<Link href="/dashboard" className="text-2xl font-bold text-blue-600"> <Link href="/dashboard" className="text-2xl font-bold text-blue-600">
<Image <Image
@ -121,7 +113,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button> </button>
</div> </div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto"> <nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{navigation.map(item => { {navigation.map(item => {
const locked = item.requiredFeature && !hasFeature(item.requiredFeature); 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' && ( {user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t"> <div className="pt-4 mt-4 border-t">
<AdminPanelDropdown /> <AdminPanelDropdown />
@ -152,7 +142,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)} )}
</nav> </nav>
{/* User section */}
<div className="border-t p-4"> <div className="border-t p-4">
<div className="flex items-center space-x-3 mb-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"> <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" 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" /> <LogOut className="w-4 h-4 mr-2" />
Déconnexion {t('logout')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Main content */}
<div className="lg:pl-64"> <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"> <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 <button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1" 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> </button>
<div className="flex-1 lg:flex-none"> <div className="flex-1 lg:flex-none">
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0"> <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> </h1>
</div> </div>
<div className="flex items-center space-x-3 lg:space-x-4"> <div className="flex items-center space-x-3 lg:space-x-4">
{/* Notifications */} <LanguageSwitcher variant="light" />
<NotificationDropdown /> <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"> <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]} {user?.firstName?.[0]}{user?.lastName?.[0]}
</Link> </Link>
</div> </div>
</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> <main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main>
</div> </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"> <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"> <div className="grid grid-cols-5 h-16">
{[ {[
{ href: '/dashboard', icon: Home, label: 'Accueil' }, { href: '/dashboard', icon: Home, label: t('bottomNav.home') },
{ href: '/dashboard/bookings', icon: Package, label: 'Réservations' }, { href: '/dashboard/bookings', icon: Package, label: t('bottomNav.bookings') },
{ href: '/dashboard/documents', icon: FileText, label: 'Documents' }, { href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
{ href: '/dashboard/track-trace', icon: Search, label: 'Suivi' }, { href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: 'Profil' }, { href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map((item) => { ].map((item) => {
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href); const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return ( return (

View File

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

View File

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

View File

@ -1,9 +1,3 @@
/**
* User Profile Page
*
* Allows users to view and update their profile information
*/
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
@ -12,45 +6,44 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { updateUser, changePassword } from '@/lib/api'; 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() { export default function ProfilePage() {
const t = useTranslations('dashboard.profile');
const { user, refreshUser, loading } = useAuth(); const { user, refreshUser, loading } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile'); const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = 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>({ const profileForm = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
defaultValues: { defaultValues: {
@ -60,7 +53,6 @@ export default function ProfilePage() {
}, },
}); });
// Password form
const passwordForm = useForm<PasswordFormData>({ const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema), resolver: zodResolver(passwordSchema),
defaultValues: { defaultValues: {
@ -70,7 +62,6 @@ export default function ProfilePage() {
}, },
}); });
// Update form values when user data loads
useEffect(() => { useEffect(() => {
if (user) { if (user) {
profileForm.reset({ profileForm.reset({
@ -82,7 +73,6 @@ export default function ProfilePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]); }, [user]);
// Reset password form when switching to password tab
useEffect(() => { useEffect(() => {
if (activeTab === 'password') { if (activeTab === 'password') {
passwordForm.reset({ passwordForm.reset({
@ -94,26 +84,24 @@ export default function ProfilePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]); }, [activeTab]);
// Update profile mutation
const updateProfileMutation = useMutation({ const updateProfileMutation = useMutation({
mutationFn: (data: ProfileFormData) => { mutationFn: (data: ProfileFormData) => {
if (!user?.id) throw new Error('User ID not found'); if (!user?.id) throw new Error('User ID not found');
return updateUser(user.id, data); return updateUser(user.id, data);
}, },
onSuccess: () => { onSuccess: () => {
setSuccessMessage('Profil mis à jour avec succès !'); setSuccessMessage(t('profileForm.successUpdate'));
setErrorMessage(''); setErrorMessage('');
refreshUser(); refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] }); queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000); setTimeout(() => setSuccessMessage(''), 3000);
}, },
onError: (error: any) => { onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du profil'); setErrorMessage(error.message || t('profileForm.errorUpdate'));
setSuccessMessage(''); setSuccessMessage('');
}, },
}); });
// Update password mutation
const updatePasswordMutation = useMutation({ const updatePasswordMutation = useMutation({
mutationFn: async (data: PasswordFormData) => { mutationFn: async (data: PasswordFormData) => {
return changePassword({ return changePassword({
@ -122,7 +110,7 @@ export default function ProfilePage() {
}); });
}, },
onSuccess: () => { onSuccess: () => {
setSuccessMessage('Mot de passe mis à jour avec succès !'); setSuccessMessage(t('passwordForm.successUpdate'));
setErrorMessage(''); setErrorMessage('');
passwordForm.reset({ passwordForm.reset({
currentPassword: '', currentPassword: '',
@ -132,7 +120,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000); setTimeout(() => setSuccessMessage(''), 3000);
}, },
onError: (error: any) => { onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe'); setErrorMessage(error.message || t('passwordForm.errorUpdate'));
setSuccessMessage(''); setSuccessMessage('');
}, },
}); });
@ -145,29 +133,27 @@ export default function ProfilePage() {
updatePasswordMutation.mutate(data); updatePasswordMutation.mutate(data);
}; };
// Show loading state while user data is being fetched
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div> <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>
</div> </div>
); );
} }
// Show error if user is not found after loading
if (!loading && !user) { if (!loading && !user) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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 <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
> >
Réessayer {t('retry')}
</button> </button>
</div> </div>
</div> </div>
@ -176,13 +162,11 @@ export default function ProfilePage() {
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <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"> <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> <h1 className="text-3xl font-bold mb-2">{t('header.title')}</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p> <p className="text-blue-100">{t('header.subtitle')}</p>
</div> </div>
{/* Success/Error Messages */}
{successMessage && ( {successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4"> <div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
@ -213,7 +197,6 @@ export default function ProfilePage() {
</div> </div>
)} )}
{/* User Info Card */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4"> <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"> <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} {user?.role}
</span> </span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full"> <span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Actif {t('active')}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="border-b"> <div className="border-b">
<nav className="flex space-x-8 px-6" aria-label="Tabs"> <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' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`} }`}
> >
Informations personnelles {t('tabs.profile')}
</button> </button>
<button <button
onClick={() => setActiveTab('password')} onClick={() => setActiveTab('password')}
@ -259,7 +241,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`} }`}
> >
Modifier le mot de passe {t('tabs.password')}
</button> </button>
</nav> </nav>
</div> </div>
@ -268,13 +250,9 @@ export default function ProfilePage() {
{activeTab === 'profile' ? ( {activeTab === 'profile' ? (
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6"> <form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* First Name */}
<div> <div>
<label <label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="firstName" {t('profileForm.firstName')}
className="block text-sm font-medium text-gray-700 mb-2"
>
Prénom
</label> </label>
<input <input
{...profileForm.register('firstName')} {...profileForm.register('firstName')}
@ -289,13 +267,9 @@ export default function ProfilePage() {
)} )}
</div> </div>
{/* Last Name */}
<div> <div>
<label <label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="lastName" {t('profileForm.lastName')}
className="block text-sm font-medium text-gray-700 mb-2"
>
Nom
</label> </label>
<input <input
{...profileForm.register('lastName')} {...profileForm.register('lastName')}
@ -311,10 +285,9 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
{/* Email */}
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Adresse email {t('profileForm.email')}
</label> </label>
<input <input
{...profileForm.register('email')} {...profileForm.register('email')}
@ -323,29 +296,24 @@ export default function ProfilePage() {
disabled disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed" 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> </div>
{/* Submit Button */}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="submit" type="submit"
disabled={updateProfileMutation.isPending} 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" 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> </button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6"> <form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
{/* Current Password */}
<div> <div>
<label <label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="currentPassword" {t('passwordForm.current')}
className="block text-sm font-medium text-gray-700 mb-2"
>
Mot de passe actuel
</label> </label>
<input <input
{...passwordForm.register('currentPassword')} {...passwordForm.register('currentPassword')}
@ -361,13 +329,9 @@ export default function ProfilePage() {
)} )}
</div> </div>
{/* New Password */}
<div> <div>
<label <label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="newPassword" {t('passwordForm.new')}
className="block text-sm font-medium text-gray-700 mb-2"
>
Nouveau mot de passe
</label> </label>
<input <input
{...passwordForm.register('newPassword')} {...passwordForm.register('newPassword')}
@ -381,18 +345,12 @@ export default function ProfilePage() {
{passwordForm.formState.errors.newPassword.message} {passwordForm.formState.errors.newPassword.message}
</p> </p>
)} )}
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">{t('passwordForm.newHint')}</p>
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
</p>
</div> </div>
{/* Confirm Password */}
<div> <div>
<label <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="confirmPassword" {t('passwordForm.confirm')}
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirmer le nouveau mot de passe
</label> </label>
<input <input
{...passwordForm.register('confirmPassword')} {...passwordForm.register('confirmPassword')}
@ -408,14 +366,13 @@ export default function ProfilePage() {
)} )}
</div> </div>
{/* Submit Button */}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="submit" type="submit"
disabled={updatePasswordMutation.isPending} 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" 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> </button>
</div> </div>
</form> </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'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/i18n/navigation';
import { Search, Loader2 } from 'lucide-react'; import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { import {
getAvailableOrigins, getAvailableOrigins,
getAvailableDestinations, getAvailableDestinations,
@ -18,10 +12,14 @@ import {
} from '@/lib/api/rates'; } from '@/lib/api/rates';
import dynamic from 'next/dynamic'; 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'), { const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
ssr: false, 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 { interface Package {
@ -35,28 +33,17 @@ interface Package {
} }
interface SearchForm { interface SearchForm {
// General
origin: string; origin: string;
destination: string; destination: string;
// Conditionnement
packages: Package[]; packages: Package[];
// Douane
eurDocument: boolean; eurDocument: boolean;
customsStop: boolean; customsStop: boolean;
exportAssistance: boolean; exportAssistance: boolean;
// Marchandise
dangerousGoods: boolean; dangerousGoods: boolean;
specialHandling: boolean; specialHandling: boolean;
// Manutention
tailgate: boolean; tailgate: boolean;
straps: boolean; straps: boolean;
thermalCover: boolean; thermalCover: boolean;
// Autres
regulatedProducts: boolean; regulatedProducts: boolean;
appointment: boolean; appointment: boolean;
insurance: boolean; insurance: boolean;
@ -64,6 +51,7 @@ interface SearchForm {
} }
export default function AdvancedSearchPage() { export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const router = useRouter(); const router = useRouter();
const [searchForm, setSearchForm] = useState<SearchForm>({ const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '', origin: '',
@ -101,20 +89,17 @@ export default function AdvancedSearchPage() {
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null); const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null); const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
// Fetch available origins from CSV rates
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({ const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'], queryKey: ['available-origins'],
queryFn: getAvailableOrigins, queryFn: getAvailableOrigins,
}); });
// Fetch available destinations based on selected origin
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({ const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin], queryKey: ['available-destinations', searchForm.origin],
queryFn: () => getAvailableDestinations(searchForm.origin), queryFn: () => getAvailableDestinations(searchForm.origin),
enabled: !!searchForm.origin, enabled: !!searchForm.origin,
}); });
// Filter origins based on search input
const filteredOrigins = (originsData?.origins || []).filter(port => { const filteredOrigins = (originsData?.origins || []).filter(port => {
if (!originSearch || originSearch.length < 1) return true; if (!originSearch || originSearch.length < 1) return true;
const searchLower = originSearch.toLowerCase(); const searchLower = originSearch.toLowerCase();
@ -126,7 +111,6 @@ export default function AdvancedSearchPage() {
); );
}); });
// Filter destinations based on search input
const filteredDestinations = (destinationsData?.destinations || []).filter(port => { const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
if (!destinationSearch || destinationSearch.length < 1) return true; if (!destinationSearch || destinationSearch.length < 1) return true;
const searchLower = destinationSearch.toLowerCase(); const searchLower = destinationSearch.toLowerCase();
@ -138,10 +122,8 @@ export default function AdvancedSearchPage() {
); );
}); });
// Reset destination when origin changes
useEffect(() => { useEffect(() => {
if (searchForm.origin && selectedDestinationPort) { if (searchForm.origin && selectedDestinationPort) {
// Check if current destination is still valid for new origin
const isValidDestination = destinationsData?.destinations?.some( const isValidDestination = destinationsData?.destinations?.some(
d => d.code === searchForm.destination d => d.code === searchForm.destination
); );
@ -153,7 +135,6 @@ export default function AdvancedSearchPage() {
} }
}, [searchForm.origin, destinationsData]); }, [searchForm.origin, destinationsData]);
// Calculate total volume and weight
const calculateTotals = () => { const calculateTotals = () => {
let totalVolumeCBM = 0; let totalVolumeCBM = 0;
let totalWeightKG = 0; let totalWeightKG = 0;
@ -174,7 +155,6 @@ export default function AdvancedSearchPage() {
const handleSearch = () => { const handleSearch = () => {
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals(); const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
// Build query parameters
const params = new URLSearchParams({ const params = new URLSearchParams({
origin: searchForm.origin, origin: searchForm.origin,
destination: searchForm.destination, destination: searchForm.destination,
@ -190,7 +170,6 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(), requiresAppointment: searchForm.appointment.toString(),
}); });
// Redirect to results page
router.push(`/dashboard/search-advanced/results?${params.toString()}`); router.push(`/dashboard/search-advanced/results?${params.toString()}`);
}; };
@ -227,13 +206,12 @@ export default function AdvancedSearchPage() {
const renderStep1 = () => ( const renderStep1 = () => (
<div className="space-y-6"> <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"> <div className="grid grid-cols-2 gap-4">
{/* Origin Port with Autocomplete - Limited to CSV routes */}
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2"> <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> </label>
<div className="relative"> <div className="relative">
<input <input
@ -242,7 +220,6 @@ export default function AdvancedSearchPage() {
onChange={e => { onChange={e => {
setOriginSearch(e.target.value); setOriginSearch(e.target.value);
setShowOriginDropdown(true); setShowOriginDropdown(true);
// Clear selection if user modifies the input
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) { if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
setSearchForm({ ...searchForm, origin: '', destination: '' }); setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null); setSelectedOriginPort(null);
@ -252,7 +229,7 @@ export default function AdvancedSearchPage() {
}} }}
onFocus={() => setShowOriginDropdown(true)} onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)} 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 ${ 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' searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
}`} }`}
@ -287,22 +264,21 @@ export default function AdvancedSearchPage() {
))} ))}
{filteredOrigins.length > 15 && ( {filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50"> <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>
)} )}
</div> </div>
)} )}
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && ( {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"> <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>
)} )}
</div> </div>
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2"> <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> </label>
<div className="relative"> <div className="relative">
<input <input
@ -311,7 +287,6 @@ export default function AdvancedSearchPage() {
onChange={e => { onChange={e => {
setDestinationSearch(e.target.value); setDestinationSearch(e.target.value);
setShowDestinationDropdown(true); setShowDestinationDropdown(true);
// Clear selection if user modifies the input
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) { if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
setSearchForm({ ...searchForm, destination: '' }); setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null); setSelectedDestinationPort(null);
@ -320,7 +295,7 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowDestinationDropdown(true)} onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)} onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin} 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 ${ 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.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`} } ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
@ -333,7 +308,7 @@ export default function AdvancedSearchPage() {
</div> </div>
{searchForm.origin && destinationsData?.total !== undefined && ( {searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1"> <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> </p>
)} )}
{showDestinationDropdown && filteredDestinations.length > 0 && ( {showDestinationDropdown && filteredDestinations.length > 0 && (
@ -358,28 +333,27 @@ export default function AdvancedSearchPage() {
))} ))}
{filteredDestinations.length > 15 && ( {filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50"> <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>
)} )}
</div> </div>
)} )}
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && ( {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"> <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> </div>
</div> </div>
{/* Carte interactive de la route maritime */}
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && ( {selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden"> <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"> <div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-900"> <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> </h3>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Distance approximative et visualisation de la route {t('step1.routeDescription')}
</p> </p>
</div> </div>
<PortRouteMap <PortRouteMap
@ -398,51 +372,53 @@ export default function AdvancedSearchPage() {
</div> </div>
); );
const renderStep2 = () => ( const renderStep2 = () => {
const totals = calculateTotals();
return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={addPackage} onClick={addPackage}
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100" 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> </button>
</div> </div>
{searchForm.packages.map((pkg, index) => ( {searchForm.packages.map((pkg, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4"> <div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between"> <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 && ( {searchForm.packages.length > 1 && (
<button <button
type="button" type="button"
onClick={() => removePackage(index)} onClick={() => removePackage(index)}
className="text-sm text-red-600 hover:text-red-700" className="text-sm text-red-600 hover:text-red-700"
> >
Supprimer {t('step2.remove')}
</button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
<div> <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 <select
value={pkg.type} value={pkg.type}
onChange={e => updatePackage(index, 'type', e.target.value)} onChange={e => updatePackage(index, 'type', e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md" className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
> >
<option value="caisse">Caisse</option> <option value="caisse">{t('step2.packageTypes.caisse')}</option>
<option value="colis">Colis</option> <option value="colis">{t('step2.packageTypes.colis')}</option>
<option value="palette">Palette</option> <option value="palette">{t('step2.packageTypes.palette')}</option>
<option value="autre">Autre</option> <option value="autre">{t('step2.packageTypes.autre')}</option>
</select> </select>
</div> </div>
<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 <input
type="number" type="number"
min="1" min="1"
@ -453,7 +429,7 @@ export default function AdvancedSearchPage() {
</div> </div>
<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 <input
type="number" type="number"
min="1" min="1"
@ -464,7 +440,7 @@ export default function AdvancedSearchPage() {
</div> </div>
<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 <input
type="number" type="number"
min="1" min="1"
@ -475,7 +451,7 @@ export default function AdvancedSearchPage() {
</div> </div>
<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 <input
type="number" type="number"
min="1" min="1"
@ -488,7 +464,7 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <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 <input
type="number" type="number"
min="1" min="1"
@ -505,30 +481,31 @@ export default function AdvancedSearchPage() {
onChange={e => updatePackage(index, 'stackable', e.target.checked)} onChange={e => updatePackage(index, 'stackable', e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
</div> </div>
))} ))}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4"> <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 className="text-sm text-blue-800 space-y-1">
<div>Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³</div> <div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
<div>Poids total: {calculateTotals().totalWeightKG} kg</div> <div>{t('step2.summary.weight', { value: totals.totalWeightKG })}</div>
<div>Palettes: {calculateTotals().totalPallets}</div> <div>{t('step2.summary.pallets', { value: totals.totalPallets })}</div>
</div> </div>
</div> </div>
</div> </div>
); );
};
const renderStep3 = () => ( const renderStep3 = () => (
<div className="space-y-6"> <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="space-y-4">
<div className="border-b pb-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"> <div className="space-y-2">
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -537,7 +514,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -546,7 +523,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -555,7 +532,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -566,13 +543,13 @@ export default function AdvancedSearchPage() {
} }
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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> </label>
</div> </div>
</div> </div>
<div className="border-b pb-4"> <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"> <div className="space-y-2">
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -581,7 +558,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -592,13 +569,13 @@ export default function AdvancedSearchPage() {
} }
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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> </label>
</div> </div>
</div> </div>
<div className="border-b pb-4"> <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"> <div className="space-y-2">
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -609,7 +586,7 @@ export default function AdvancedSearchPage() {
} }
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -618,7 +595,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -627,7 +604,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -636,13 +613,13 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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> </label>
</div> </div>
</div> </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"> <div className="space-y-2">
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -651,7 +628,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@ -660,7 +637,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })} onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded" 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> </label>
</div> </div>
</div> </div>
@ -670,15 +647,13 @@ export default function AdvancedSearchPage() {
return ( return (
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div> <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"> <p className="text-sm text-gray-500 mt-1">
Formulaire complet avec toutes les options de transport {t('subtitle')}
</p> </p>
</div> </div>
{/* Progress Steps */}
<div className="flex items-center justify-center space-x-4"> <div className="flex items-center justify-center space-x-4">
{[1, 2, 3].map(step => ( {[1, 2, 3].map(step => (
<div key={step} className="flex items-center"> <div key={step} className="flex items-center">
@ -700,13 +675,11 @@ export default function AdvancedSearchPage() {
))} ))}
</div> </div>
{/* Form */}
<div className="bg-white rounded-lg shadow p-8"> <div className="bg-white rounded-lg shadow p-8">
{currentStep === 1 && renderStep1()} {currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()} {currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()} {currentStep === 3 && renderStep3()}
{/* Navigation */}
<div className="mt-8 flex items-center justify-between pt-6 border-t"> <div className="mt-8 flex items-center justify-between pt-6 border-t">
<button <button
type="button" type="button"
@ -714,7 +687,7 @@ export default function AdvancedSearchPage() {
disabled={currentStep === 1} 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" 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> </button>
{currentStep < 3 ? ( {currentStep < 3 ? (
@ -724,7 +697,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination} 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" 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>
) : ( ) : (
<button <button
@ -733,7 +706,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination} 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" 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> </button>
)} )}
</div> </div>

View File

@ -1,7 +1,9 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; 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 { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchResult } from '@/types/rates'; import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react'; import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
@ -13,13 +15,15 @@ interface BestOptions {
} }
export default function SearchResultsPage() { 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 router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [results, setResults] = useState<CsvRateSearchResult[]>([]); const [results, setResults] = useState<CsvRateSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Parse search parameters from URL
const origin = searchParams.get('origin') || ''; const origin = searchParams.get('origin') || '';
const destination = searchParams.get('destination') || ''; const destination = searchParams.get('destination') || '';
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0'); const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
@ -49,11 +53,11 @@ export default function SearchResultsPage() {
setResults(response.results); setResults(response.results);
} catch (err) { } catch (err) {
console.error('Search error:', 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams]); }, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]);
useEffect(() => { useEffect(() => {
if (!origin || !destination || !volumeCBM || !weightKG) { if (!origin || !destination || !volumeCBM || !weightKG) {
@ -67,12 +71,10 @@ export default function SearchResultsPage() {
const getBestOptions = (): BestOptions | null => { const getBestOptions = (): BestOptions | null => {
if (results.length === 0) return 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 economic = results.find(r => r.serviceLevel === 'ECONOMIC');
const standard = results.find(r => r.serviceLevel === 'STANDARD'); const standard = results.find(r => r.serviceLevel === 'STANDARD');
const rapid = results.find(r => r.serviceLevel === 'RAPID'); const rapid = results.find(r => r.serviceLevel === 'RAPID');
// If we have all 3 service levels, return them
if (economic && standard && rapid) { if (economic && standard && rapid) {
return { return {
eco: economic, 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 sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays); const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
@ -95,7 +96,7 @@ export default function SearchResultsPage() {
const bestOptions = getBestOptions(); const bestOptions = getBestOptions();
const formatPrice = (price: number) => { const formatPrice = (price: number) => {
return new Intl.NumberFormat('fr-FR', { return new Intl.NumberFormat(dateLocale, {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
}).format(price); }).format(price);
@ -107,7 +108,7 @@ export default function SearchResultsPage() {
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="text-center"> <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> <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"> <p className="text-gray-500 mt-2">
{origin} {destination} {origin} {destination}
</p> </p>
@ -123,13 +124,13 @@ export default function SearchResultsPage() {
<div className="max-w-7xl mx-auto"> <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="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> <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> <p className="text-red-700 mb-4">{error}</p>
<button <button
onClick={() => router.push('/dashboard/search-advanced')} onClick={() => router.push('/dashboard/search-advanced')}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
> >
Retour à la recherche {t('backToSearch')}
</button> </button>
</div> </div>
</div> </div>
@ -145,35 +146,30 @@ export default function SearchResultsPage() {
onClick={() => router.back()} onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium" className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
> >
Retour à la recherche {t('backToSearch')}
</button> </button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center"> <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> <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"> <p className="text-gray-600 mb-4">
Aucun tarif ne correspond à votre recherche pour le trajet {origin} {destination} {t('noResultsMessage', { origin, destination })}
</p> </p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6"> <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"> <ul className="text-sm text-gray-700 space-y-2">
<li> <li>{t('suggestionPorts')}</li>
<strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX, <li>{t('suggestionVolume')}</li>
CNSHG, SGSIN (destination) <li>{t('suggestionWeight')}</li>
</li>
<li>
<strong>Volume :</strong> Essayez entre 1 et 200 CBM
</li>
<li>
<strong>Poids :</strong> Essayez entre 100 et 30000 kg
</li>
</ul> </ul>
</div> </div>
<button <button
onClick={() => router.back()} onClick={() => router.back()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
Modifier la recherche {t('modifySearch')}
</button> </button>
</div> </div>
</div> </div>
@ -183,7 +179,7 @@ export default function SearchResultsPage() {
const optionCards = [ const optionCards = [
{ {
type: 'Économique', type: t('options.economic'),
option: bestOptions?.eco, option: bestOptions?.eco,
colors: { colors: {
border: 'border-green-200', border: 'border-green-200',
@ -192,10 +188,10 @@ export default function SearchResultsPage() {
button: 'bg-green-600 hover:bg-green-700', button: 'bg-green-600 hover:bg-green-700',
}, },
icon: <DollarSign className="h-10 w-10 text-green-600" />, 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, option: bestOptions?.standard,
colors: { colors: {
border: 'border-blue-200', border: 'border-blue-200',
@ -204,10 +200,10 @@ export default function SearchResultsPage() {
button: 'bg-blue-600 hover:bg-blue-700', button: 'bg-blue-600 hover:bg-blue-700',
}, },
icon: <Scale className="h-10 w-10 text-blue-600" />, 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, option: bestOptions?.fast,
colors: { colors: {
border: 'border-purple-200', border: 'border-purple-200',
@ -216,7 +212,7 @@ export default function SearchResultsPage() {
button: 'bg-purple-600 hover:bg-purple-700', button: 'bg-purple-600 hover:bg-purple-700',
}, },
icon: <Zap className="h-10 w-10 text-purple-600" />, 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()} onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium" className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
> >
Retour à la recherche {t('backToSearch')}
</button> </button>
<div className="bg-white rounded-lg shadow-md p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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"> <p className="text-gray-600">
<span className="font-semibold">{origin}</span> <span className="font-semibold">{destination}</span>{' '} <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> </p>
</div> </div>
<div className="text-right"> <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> <p className="text-3xl font-bold text-blue-600">{results.length}</p>
</div> </div>
</div> </div>
@ -255,7 +253,7 @@ export default function SearchResultsPage() {
<div className="mb-12"> <div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center"> <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" /> <Trophy className="h-8 w-8 mr-3 text-yellow-500" />
Meilleurs choix pour votre recherche {t('bestChoices')}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <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="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3"> <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> <p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p>
</div> </div>
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm"> <div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
<div className="flex justify-between"> <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> <span className="font-semibold text-gray-900">{card.option.companyName}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Transit :</span> <span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span> <span className="font-semibold text-gray-900">
{t('transitDays', { days: card.option.transitDays })}
</span>
</div> </div>
<div className="flex justify-between"> <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> <span className="font-semibold text-gray-900">{card.option.containerType}</span>
</div> </div>
</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`} className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
> >
Sélectionner cette option {t('select')}
</button> </button>
</div> </div>
</div> </div>
@ -321,7 +321,7 @@ export default function SearchResultsPage() {
{/* All Results */} {/* All Results */}
<div> <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"> <div className="space-y-4">
{results.map((result, index) => ( {results.map((result, index) => (
@ -335,39 +335,45 @@ export default function SearchResultsPage() {
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p> <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> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3"> <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"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.basePrice)} {formatPrice(result.priceBreakdown.basePrice)}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <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"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.volumeCharge)} {formatPrice(result.priceBreakdown.volumeCharge)}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <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"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.weightCharge)} {formatPrice(result.priceBreakdown.weightCharge)}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Délai transit</p> <p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p>
<p className="font-semibold text-gray-900">{result.transitDays} jours</p> <p className="font-semibold text-gray-900">
{t('transitDays', { days: result.transitDays })}
</p>
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600"> <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> <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" /> Surcharges applicables</span>} {result.hasSurcharges && (
<span className="text-orange-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> {t('surcharges')}
</span>
)}
</div> </div>
<button <button
onClick={() => { 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" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
Sélectionner {t('selectShort')}
</button> </button>
</div> </div>
</div> </div>

View File

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

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/context/auth-context'; import { useAuth } from '@/lib/context/auth-context';
import { getOrganization, updateOrganization } from '@/lib/api/organizations'; import { getOrganization, updateOrganization } from '@/lib/api/organizations';
import type { OrganizationResponse } from '@/types/api'; import type { OrganizationResponse } from '@/types/api';
@ -23,11 +24,11 @@ interface OrganizationForm {
type TabType = 'information' | 'address' | 'subscription' | 'licenses'; type TabType = 'information' | 'address' | 'subscription' | 'licenses';
export default function OrganizationSettingsPage() { export default function OrganizationSettingsPage() {
const t = useTranslations('dashboard.organizationSettings');
const { user } = useAuth(); const { user } = useAuth();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<TabType>('information'); const [activeTab, setActiveTab] = useState<TabType>('information');
// Auto-switch to subscription tab if coming back from Stripe (only for ADMIN/MANAGER)
useEffect(() => { useEffect(() => {
const isSuccess = searchParams.get('success') === 'true'; const isSuccess = searchParams.get('success') === 'true';
const isCanceled = searchParams.get('canceled') === 'true'; const isCanceled = searchParams.get('canceled') === 'true';
@ -53,7 +54,6 @@ export default function OrganizationSettingsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = 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 canEdit = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const loadOrganization = useCallback(async () => { const loadOrganization = useCallback(async () => {
@ -77,11 +77,11 @@ export default function OrganizationSettingsPage() {
}); });
} catch (err) { } catch (err) {
console.error('Failed to load organization:', 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [user]); }, [user, t]);
useEffect(() => { useEffect(() => {
if (user?.organizationId) { if (user?.organizationId) {
@ -135,10 +135,10 @@ export default function OrganizationSettingsPage() {
}); });
setOrganization(updatedOrg); setOrganization(updatedOrg);
setSuccessMessage('Informations sauvegardées avec succès'); setSuccessMessage(t('saveSuccess'));
} catch (err) { } catch (err) {
console.error('Failed to update organization:', 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 { } finally {
setIsSaving(false); setIsSaving(false);
} }
@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() {
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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> <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>
</div> </div>
); );
@ -159,20 +159,19 @@ export default function OrganizationSettingsPage() {
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-6"> <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> <h3 className="text-lg font-semibold text-red-900 mb-2">{t('errorTitle')}</h3>
<p className="text-red-700">{error || "Impossible de charger l'organisation"}</p> <p className="text-red-700">{error || t('loadError')}</p>
</div> </div>
</div> </div>
); );
} }
// Check if user can view subscription and licenses (only ADMIN and MANAGER)
const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER'; const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const tabs = [ const tabs = [
{ {
id: 'information' as TabType, id: 'information' as TabType,
label: 'Informations', label: t('tabs.information'),
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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, id: 'address' as TabType,
label: 'Adresse', label: t('tabs.address'),
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
}, },
// Only show subscription and licenses tabs for ADMIN and MANAGER roles
...(canViewBilling ? [ ...(canViewBilling ? [
{ {
id: 'subscription' as TabType, id: 'subscription' as TabType,
label: 'Abonnement', label: t('tabs.subscription'),
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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, id: 'licenses' as TabType,
label: 'Licences', label: t('tabs.licenses'),
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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 ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l&apos;organisation</h1> <h1 className="text-3xl font-bold text-gray-900">{t('header.title')}</h1>
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p> <p className="text-gray-600 mt-2">{t('header.subtitle')}</p>
</div> </div>
{/* Success Message */}
{successMessage && (activeTab === 'information' || activeTab === 'address') && ( {successMessage && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4"> <div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
@ -232,7 +228,6 @@ export default function OrganizationSettingsPage() {
</div> </div>
)} )}
{/* Error Message */}
{error && (activeTab === 'information' || activeTab === 'address') && ( {error && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4"> <div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
@ -244,19 +239,17 @@ export default function OrganizationSettingsPage() {
</div> </div>
)} )}
{/* Read-only warning for USER role */}
{!canEdit && (activeTab === 'information' || activeTab === 'address') && ( {!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<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>
</div> </div>
)} )}
{/* Tabs */}
<div className="bg-white rounded-lg shadow-md"> <div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200"> <div className="border-b border-gray-200">
<nav className="flex -mb-px overflow-x-auto"> <nav className="flex -mb-px overflow-x-auto">
@ -279,14 +272,12 @@ export default function OrganizationSettingsPage() {
</nav> </nav>
</div> </div>
{/* Tab Content */}
<div className="p-8"> <div className="p-8">
{activeTab === 'information' && ( {activeTab === 'information' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Nom de la société */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Nom de la société <span className="text-red-500">*</span> {t('information.name')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -294,16 +285,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('name', e.target.value)} onChange={e => handleChange('name', e.target.value)}
disabled={!canEdit} 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" 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 required
/> />
</div> </div>
{/* SIREN */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
SIREN {t('information.siren')}
<span className="ml-2 text-xs text-gray-500">(Système d&apos;Identification du Répertoire des Entreprises)</span> <span className="ml-2 text-xs text-gray-500">({t('information.sirenHint')})</span>
</label> </label>
<input <input
type="text" type="text"
@ -311,17 +301,16 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))} onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))}
disabled={!canEdit} 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" 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} 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> </div>
{/* Numéro EORI */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Numéro EORI {t('information.eori')}
<span className="ml-2 text-xs text-gray-500">(Economic Operators Registration and Identification)</span> <span className="ml-2 text-xs text-gray-500">({t('information.eoriHint')})</span>
</label> </label>
<input <input
type="text" type="text"
@ -329,35 +318,33 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('eori', e.target.value.toUpperCase())} onChange={e => handleChange('eori', e.target.value.toUpperCase())}
disabled={!canEdit} 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" 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} 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> </div>
{/* Téléphone */}
<div> <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 <input
type="tel" type="tel"
value={formData.contact_phone} value={formData.contact_phone}
onChange={e => handleChange('contact_phone', e.target.value)} onChange={e => handleChange('contact_phone', e.target.value)}
disabled={!canEdit} 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" 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> </div>
{/* Email */}
<div> <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 <input
type="email" type="email"
value={formData.contact_email} value={formData.contact_email}
onChange={e => handleChange('contact_email', e.target.value)} onChange={e => handleChange('contact_email', e.target.value)}
disabled={!canEdit} 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" 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>
</div> </div>
@ -365,10 +352,9 @@ export default function OrganizationSettingsPage() {
{activeTab === 'address' && ( {activeTab === 'address' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Rue */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Rue <span className="text-red-500">*</span> {t('address.street')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -376,16 +362,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_street', e.target.value)} onChange={e => handleChange('address_street', e.target.value)}
disabled={!canEdit} 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" 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 required
/> />
</div> </div>
{/* Ville et Code postal */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <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> </label>
<input <input
type="text" type="text"
@ -393,13 +378,13 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_postal_code', e.target.value)} onChange={e => handleChange('address_postal_code', e.target.value)}
disabled={!canEdit} 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" 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 required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Ville <span className="text-red-500">*</span> {t('address.city')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -407,16 +392,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_city', e.target.value)} onChange={e => handleChange('address_city', e.target.value)}
disabled={!canEdit} 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" 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 required
/> />
</div> </div>
</div> </div>
{/* Pays */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Pays <span className="text-red-500">*</span> {t('address.country')} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={formData.address_country} 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" 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 required
> >
<option value="FR">France</option> <option value="FR">{t('address.countries.FR')}</option>
<option value="BE">Belgique</option> <option value="BE">{t('address.countries.BE')}</option>
<option value="DE">Allemagne</option> <option value="DE">{t('address.countries.DE')}</option>
<option value="ES">Espagne</option> <option value="ES">{t('address.countries.ES')}</option>
<option value="IT">Italie</option> <option value="IT">{t('address.countries.IT')}</option>
<option value="NL">Pays-Bas</option> <option value="NL">{t('address.countries.NL')}</option>
<option value="GB">Royaume-Uni</option> <option value="GB">{t('address.countries.GB')}</option>
<option value="US">États-Unis</option> <option value="US">{t('address.countries.US')}</option>
<option value="CN">Chine</option> <option value="CN">{t('address.countries.CN')}</option>
</select> </select>
</div> </div>
</div> </div>
@ -444,7 +428,6 @@ export default function OrganizationSettingsPage() {
{activeTab === 'licenses' && canViewBilling && <LicensesTab />} {activeTab === 'licenses' && canViewBilling && <LicensesTab />}
</div> </div>
{/* Actions (only for information and address tabs) */}
{canEdit && (activeTab === 'information' || activeTab === 'address') && ( {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"> <div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
<button <button
@ -453,7 +436,7 @@ export default function OrganizationSettingsPage() {
disabled={isSaving} 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" 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>
<button <button
type="button" type="button"
@ -464,10 +447,10 @@ export default function OrganizationSettingsPage() {
{isSaving ? ( {isSaving ? (
<> <>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <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> </button>
</div> </div>

View File

@ -1,20 +1,16 @@
/**
* Subscription Management Page
*
* Redirects to Organization settings with Subscription tab
*/
'use client'; 'use client';
import { useEffect } from 'react'; 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() { export default function SubscriptionPage() {
const t = useTranslations('dashboard.subscriptionRedirect');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
// Preserve any query parameters (success, canceled) from Stripe redirects
const params = searchParams.toString(); const params = searchParams.toString();
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`; const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
router.replace(redirectUrl); router.replace(redirectUrl);
@ -24,7 +20,7 @@ export default function SubscriptionPage() {
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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> <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>
</div> </div>
); );

View File

@ -1,18 +1,12 @@
/**
* User Management Page
*
* Manage organization users, roles, and invitations
*/
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api'; import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations'; import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context'; import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link'; import { Link, useRouter } from '@/i18n/navigation';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
@ -27,13 +21,18 @@ function Pagination({
total: number; total: number;
onPage: (p: number) => void; onPage: (p: number) => void;
}) { }) {
const t = useTranslations('dashboard.usersManagement.pagination');
const totalPages = Math.ceil(total / PAGE_SIZE); const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) return null; if (totalPages <= 1) return null;
return ( return (
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200"> <div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-500"> <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> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@ -69,6 +68,9 @@ function Pagination({
} }
export default function UsersManagementPage() { export default function UsersManagementPage() {
const t = useTranslations('dashboard.usersManagement');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
@ -107,14 +109,14 @@ export default function UsersManagementPage() {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] });
queryClient.invalidateQueries({ queryKey: ['invitations'] }); 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); setShowInviteModal(false);
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' }); setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
setInvitationsPage(1); setInvitationsPage(1);
setTimeout(() => setSuccess(''), 5000); setTimeout(() => setSuccess(''), 5000);
}, },
onError: (err: any) => { 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); setTimeout(() => setError(''), 5000);
}, },
}); });
@ -124,11 +126,11 @@ export default function UsersManagementPage() {
updateUser(id, { role }), updateUser(id, { role }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Rôle mis à jour avec succès'); setSuccess(t('messages.roleSuccess'));
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
}, },
onError: (err: any) => { 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); setTimeout(() => setError(''), 5000);
}, },
}); });
@ -139,11 +141,11 @@ export default function UsersManagementPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess("Statut de l'utilisateur mis à jour avec succès"); setSuccess(t('messages.statusSuccess'));
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
}, },
onError: (err: any) => { 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); setTimeout(() => setError(''), 5000);
}, },
}); });
@ -153,11 +155,11 @@ export default function UsersManagementPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Utilisateur supprimé avec succès'); setSuccess(t('messages.deleteSuccess'));
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
}, },
onError: (err: any) => { 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); setTimeout(() => setError(''), 5000);
}, },
}); });
@ -167,11 +169,11 @@ export default function UsersManagementPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invitations'] }); queryClient.invalidateQueries({ queryKey: ['invitations'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation annulée avec succès'); setSuccess(t('messages.cancelInviteSuccess'));
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
}, },
onError: (err: any) => { 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); setTimeout(() => setError(''), 5000);
}, },
}); });
@ -201,19 +203,20 @@ export default function UsersManagementPage() {
}; };
const handleToggleActive = (userId: string, isActive: boolean) => { 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 }); toggleActiveMutation.mutate({ id: userId, isActive });
} }
}; };
const handleDelete = (userId: string) => { 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); deleteMutation.mutate(userId);
} }
}; };
const handleCancelInvitation = (invId: string, name: string) => { 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); cancelInvitationMutation.mutate(invId);
} }
}; };
@ -236,7 +239,6 @@ export default function UsersManagementPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* License Warning */}
{licenseStatus && !licenseStatus.canInvite && ( {licenseStatus && !licenseStatus.canInvite && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start"> <div className="flex items-start">
@ -246,14 +248,13 @@ export default function UsersManagementPage() {
</svg> </svg>
</div> </div>
<div className="ml-3 flex-1"> <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"> <p className="mt-1 text-sm text-amber-700">
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}). {t('license.limitMessage', { used: licenseStatus.usedLicenses, max: licenseStatus.maxLicenses })}
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
</p> </p>
<div className="mt-3"> <div className="mt-3">
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"> <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> </Link>
</div> </div>
</div> </div>
@ -261,7 +262,6 @@ export default function UsersManagementPage() {
</div> </div>
)} )}
{/* License Usage Info */}
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && ( {licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between"> <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" /> <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> </svg>
<span className="text-sm text-blue-800"> <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> </span>
</div> </div>
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800"> <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> </Link>
</div> </div>
</div> </div>
)} )}
<PageHeader <PageHeader
title="Gestion des Utilisateurs" title={t('header.title')}
description="Gérez les membres de l'équipe et leurs permissions" description={t('header.subtitle')}
actions={ actions={
<> <>
<ExportButton <ExportButton
data={allUsers} data={allUsers}
filename="utilisateurs" filename={t('exportFilename')}
columns={[ columns={[
{ key: 'firstName', label: 'Prénom' }, { key: 'firstName', label: t('export.firstName') },
{ key: 'lastName', label: 'Nom' }, { key: 'lastName', label: t('export.lastName') },
{ key: 'email', label: 'Email' }, { key: 'email', label: t('export.email') },
{ key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) }, { key: 'role', label: t('export.role'), format: (v) => t(`modal.rolesExport.${v}` as any) || v },
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' }, { key: 'isActive', label: t('export.status'), format: (v) => v ? t('users.active') : t('users.inactive') },
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' }, { key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
]} ]}
/> />
{licenseStatus?.canInvite ? ( {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" 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="mr-1.5">+</span>
<span className="hidden sm:inline">Inviter un utilisateur</span> <span className="hidden sm:inline">{t('actions.invite')}</span>
<span className="sm:hidden">Inviter</span> <span className="sm:hidden">{t('actions.inviteShort')}</span>
</button> </button>
) : ( ) : (
<Link <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" 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="mr-1.5">+</span>
<span className="hidden sm:inline">Mettre à niveau</span> <span className="hidden sm:inline">{t('actions.upgrade')}</span>
<span className="sm:hidden">Upgrade</span> <span className="sm:hidden">{t('actions.upgradeShort')}</span>
</Link> </Link>
)} )}
</> </>
@ -332,18 +336,17 @@ export default function UsersManagementPage() {
</div> </div>
)} )}
{/* Users Table */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200"> <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 && ( {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> </div>
{isLoading ? ( {isLoading ? (
<div className="px-6 py-12 text-center text-gray-500"> <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> <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> </div>
) : pagedUsers.length > 0 ? ( ) : pagedUsers.length > 0 ? (
<> <>
@ -351,12 +354,12 @@ export default function UsersManagementPage() {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</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">Email</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">Rôle</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">Statut</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">Date de création</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">Actions</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> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -387,19 +390,19 @@ export default function UsersManagementPage() {
user.id === currentUser?.id user.id === currentUser?.id
} }
> >
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>} {currentUser?.role === 'ADMIN' && <option value="ADMIN">{t('modal.roles.ADMIN')}</option>}
<option value="MANAGER">Manager</option> <option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="USER">User</option> <option value="USER">{t('modal.roles.USER')}</option>
<option value="VIEWER">Viewer</option> <option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select> </select>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <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'}`}> <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> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('fr-FR')} {new Date(user.createdAt).toLocaleDateString(dateLocale)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <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"> <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" /> <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> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3> <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">Commencez par inviter un membre de l'équipe</p> <p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
<div className="mt-6"> <div className="mt-6">
{licenseStatus?.canInvite ? ( {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"> <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> <span className="mr-2">+</span>
Inviter un utilisateur {t('actions.invite')}
</button> </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"> <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> <span className="mr-2">+</span>
Mettre à niveau {t('actions.upgrade')}
</Link> </Link>
)} )}
</div> </div>
@ -451,25 +454,24 @@ export default function UsersManagementPage() {
)} )}
</div> </div>
{/* Pending Invitations */}
{allPending.length > 0 && ( {allPending.length > 0 && (
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200"> <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"> <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> </p>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</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">Email</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">Rôle</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">Expire le</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">Statut</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">Actions</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> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -494,11 +496,11 @@ export default function UsersManagementPage() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(inv.expiresAt).toLocaleDateString('fr-FR')} {new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <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'}`}> <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> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
Annuler {t('invitations.cancel')}
</button> </button>
</td> </td>
</tr> </tr>
@ -523,7 +525,6 @@ export default function UsersManagementPage() {
</div> </div>
)} )}
{/* Actions Menu Modal */}
{openMenuId && menuPosition && ( {openMenuId && menuPosition && (
<> <>
<div <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"> <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" /> <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> </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"> <svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span className="text-sm font-medium text-gray-700">Activer</span> <span className="text-sm font-medium text-gray-700">{t('users.actions.activate')}</span>
</> </>
)} )}
</button> </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"> <svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
<span className="text-sm font-medium text-red-600">Supprimer</span> <span className="text-sm font-medium text-red-600">{t('users.actions.delete')}</span>
</button> </button>
</div> </div>
</div> </div>
</> </>
)} )}
{/* Invite Modal */}
{showInviteModal && ( {showInviteModal && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <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"> <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 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>
<div className="flex items-center justify-between mb-4"> <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"> <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"> <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" /> <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"> <form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Prénom *</label> <label className="block text-sm font-medium text-gray-700">{t('modal.firstName')} *</label>
<input <input
type="text" type="text"
required required
@ -608,7 +608,7 @@ export default function UsersManagementPage() {
/> />
</div> </div>
<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 <input
type="text" type="text"
required required
@ -619,7 +619,7 @@ export default function UsersManagementPage() {
</div> </div>
</div> </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 <input
type="email" type="email"
required required
@ -629,15 +629,15 @@ export default function UsersManagementPage() {
/> />
</div> </div>
<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 <select
value={inviteForm.role} value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })} 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" 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="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">Manager</option> <option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="VIEWER">Lecteur</option> <option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select> </select>
</div> </div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> <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} 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" 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>
<button <button
type="button" type="button"
onClick={() => setShowInviteModal(false)} 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" 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> </button>
</div> </div>
</form> </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'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -18,7 +12,6 @@ import {
ClipboardList, ClipboardList,
Lightbulb, Lightbulb,
History, History,
MapPin,
X, X,
Clock, Clock,
Ship, Ship,
@ -29,7 +22,6 @@ import {
Anchor, Anchor,
} from 'lucide-react'; } from 'lucide-react';
// Search history item type
interface SearchHistoryItem { interface SearchHistoryItem {
id: string; id: string;
trackingNumber: string; trackingNumber: string;
@ -38,114 +30,26 @@ interface SearchHistoryItem {
timestamp: Date; timestamp: Date;
} }
// Carrier tracking URLs with official brand colors type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
const carriers = [ const carriers = [
{ { id: 'maersk', name: 'Maersk', color: '#00243D', textColor: 'text-white', trackingUrl: 'https://www.maersk.com/tracking/', placeholder: 'Ex: MSKU1234567', descKey: 'containerOrBl' as CarrierDescKey },
id: 'maersk', { 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 },
name: 'Maersk', { 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 },
color: '#00243D', // Maersk dark blue { 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 },
textColor: 'text-white', { id: 'cosco', name: 'COSCO', color: '#003A70', textColor: 'text-white', trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=', placeholder: 'Ex: COSU1234567', descKey: 'containerOrBl' as CarrierDescKey },
trackingUrl: 'https://www.maersk.com/tracking/', { 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 },
placeholder: 'Ex: MSKU1234567', { 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 },
description: 'N° conteneur ou B/L', { 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 },
logo: '/assets/logos/carriers/maersk.svg', { 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 },
{
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',
},
]; ];
// Local storage keys
const HISTORY_KEY = 'xpeditis_track_history'; const HISTORY_KEY = 'xpeditis_track_history';
export default function TrackTracePage() { export default function TrackTracePage() {
const t = useTranslations('dashboard.trackTrace');
const locale = useLocale();
const [trackingNumber, setTrackingNumber] = useState(''); const [trackingNumber, setTrackingNumber] = useState('');
const [selectedCarrier, setSelectedCarrier] = useState(''); const [selectedCarrier, setSelectedCarrier] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -154,7 +58,6 @@ export default function TrackTracePage() {
const [isMapFullscreen, setIsMapFullscreen] = useState(false); const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const [isMapLoading, setIsMapLoading] = useState(true); const [isMapLoading, setIsMapLoading] = useState(true);
// Load history from localStorage on mount
useEffect(() => { useEffect(() => {
const savedHistory = localStorage.getItem(HISTORY_KEY); const savedHistory = localStorage.getItem(HISTORY_KEY);
if (savedHistory) { if (savedHistory) {
@ -170,7 +73,6 @@ export default function TrackTracePage() {
} }
}, []); }, []);
// Save to localStorage
const saveHistory = (history: SearchHistoryItem[]) => { const saveHistory = (history: SearchHistoryItem[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
setSearchHistory(history); setSearchHistory(history);
@ -178,11 +80,11 @@ export default function TrackTracePage() {
const handleTrack = () => { const handleTrack = () => {
if (!trackingNumber.trim()) { if (!trackingNumber.trim()) {
setError('Veuillez entrer un numéro de tracking'); setError(t('errors.noTrackingNumber'));
return; return;
} }
if (!selectedCarrier) { if (!selectedCarrier) {
setError('Veuillez sélectionner un transporteur'); setError(t('errors.noCarrier'));
return; return;
} }
@ -190,7 +92,6 @@ export default function TrackTracePage() {
const carrier = carriers.find(c => c.id === selectedCarrier); const carrier = carriers.find(c => c.id === selectedCarrier);
if (carrier) { if (carrier) {
// Add to history
const newHistoryItem: SearchHistoryItem = { const newHistoryItem: SearchHistoryItem = {
id: Date.now().toString(), id: Date.now().toString(),
trackingNumber: trackingNumber.trim(), trackingNumber: trackingNumber.trim(),
@ -199,7 +100,6 @@ export default function TrackTracePage() {
timestamp: new Date(), timestamp: new Date(),
}; };
// Keep only last 10 unique searches
const updatedHistory = [newHistoryItem, ...searchHistory.filter( const updatedHistory = [newHistoryItem, ...searchHistory.filter(
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId) h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
)].slice(0, 10); )].slice(0, 10);
@ -240,21 +140,19 @@ export default function TrackTracePage() {
const diffHours = Math.floor(diffMs / 3600000); const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000); const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'À l\'instant'; if (diffMins < 1) return t('timeAgo.justNow');
if (diffMins < 60) return `Il y a ${diffMins}min`; if (diffMins < 60) return t('timeAgo.minutesAgo', { count: diffMins });
if (diffHours < 24) return `Il y a ${diffHours}h`; if (diffHours < 24) return t('timeAgo.hoursAgo', { count: diffHours });
if (diffDays < 7) return `Il y a ${diffDays}j`; if (diffDays < 7) return t('timeAgo.daysAgo', { count: diffDays });
return date.toLocaleDateString('fr-FR'); return date.toLocaleDateString(locale);
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1> <h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-2 text-gray-600"> <p className="mt-2 text-gray-600">{t('description')}</p>
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
</p>
</div> </div>
{/* Search Form */} {/* Search Form */}
@ -262,17 +160,15 @@ export default function TrackTracePage() {
<CardHeader> <CardHeader>
<CardTitle className="text-xl flex items-center gap-2"> <CardTitle className="text-xl flex items-center gap-2">
<Search className="h-5 w-5 text-blue-600" /> <Search className="h-5 w-5 text-blue-600" />
Rechercher une expédition {t('searchCard.title')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('searchCard.description')}</CardDescription>
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */} {/* Carrier Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-3"> <label className="block text-sm font-medium text-gray-700 mb-3">
Sélectionnez le transporteur {t('searchCard.selectCarrier')}
</label> </label>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{carriers.map(carrier => ( {carriers.map(carrier => (
@ -289,7 +185,6 @@ export default function TrackTracePage() {
: 'border-gray-200 hover:border-gray-300 hover:shadow-md' : 'border-gray-200 hover:border-gray-300 hover:shadow-md'
}`} }`}
> >
{/* Carrier logo/badge with brand color */}
<div <div
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`} 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 }} style={{ backgroundColor: carrier.color }}
@ -305,7 +200,7 @@ export default function TrackTracePage() {
{/* Tracking Number Input */} {/* Tracking Number Input */}
<div> <div>
<label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2">
Numéro de tracking {t('searchCard.trackingNumber')}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1"> <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" className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
/> />
{selectedCarrierData && ( {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> </div>
{/* US 5.2: Harmonized button color */}
<Button <Button
onClick={handleTrack} onClick={handleTrack}
size="lg" size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md" 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" /> <Search className="mr-2 h-5 w-5" />
Rechercher {t('searchCard.searchButton')}
</Button> </Button>
</div> </div>
</div> </div>
{/* Action Button - Map */} {/* Map Toggle */}
<div className="flex flex-wrap gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
<Button <Button
variant={showMap ? "default" : "outline"} variant={showMap ? "default" : "outline"}
@ -351,7 +247,7 @@ export default function TrackTracePage() {
} }
> >
<Globe className="mr-2 h-4 w-4" /> <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> </Button>
</div> </div>
@ -364,7 +260,7 @@ export default function TrackTracePage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Vessel Position Map - Large immersive display */} {/* Vessel Position Map */}
{showMap && ( {showMap && (
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}> <div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}> <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" /> <Globe className="h-6 w-6" />
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold">Carte Maritime Mondiale</h3> <h3 className="text-lg font-semibold">{t('map.title')}</h3>
<p className="text-blue-100 text-sm">Position des navires en temps réel</p> <p className="text-blue-100 text-sm">{t('map.subtitle')}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Fullscreen Toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -390,16 +285,15 @@ export default function TrackTracePage() {
{isMapFullscreen ? ( {isMapFullscreen ? (
<> <>
<Minimize2 className="h-4 w-4 mr-2" /> <Minimize2 className="h-4 w-4 mr-2" />
Réduire {t('map.minimize')}
</> </>
) : ( ) : (
<> <>
<Maximize2 className="h-4 w-4 mr-2" /> <Maximize2 className="h-4 w-4 mr-2" />
Plein écran {t('map.fullscreen')}
</> </>
)} )}
</Button> </Button>
{/* Close Button */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -416,7 +310,6 @@ export default function TrackTracePage() {
{/* Map Container */} {/* Map Container */}
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}> <div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
{/* Loading State */}
{isMapLoading && ( {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="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
<div className="text-center"> <div className="text-center">
@ -430,17 +323,16 @@ export default function TrackTracePage() {
</div> </div>
</div> </div>
</div> </div>
<p className="mt-4 text-blue-700 font-medium">Chargement de la carte...</p> <p className="mt-4 text-blue-700 font-medium">{t('map.loading')}</p>
<p className="text-blue-500 text-sm">Connexion à MarineTraffic</p> <p className="text-blue-500 text-sm">{t('map.connecting')}</p>
</div> </div>
</div> </div>
)} )}
{/* MarineTraffic Map */}
<iframe <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" 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" className="w-full h-full border-0"
title="Carte maritime en temps réel" title={t('map.iframeTitle')}
loading="lazy" loading="lazy"
onLoad={() => setIsMapLoading(false)} 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]'}`}> <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"> <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" /> <Anchor className="h-4 w-4 text-blue-600" />
Légende {t('map.legend')}
</h4> </h4>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" /> <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="flex items-center gap-4 text-sm">
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold text-blue-600">90K+</p> <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>
<div className="w-px h-10 bg-gray-200" /> <div className="w-px h-10 bg-gray-200" />
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold text-green-600">3,500+</p> <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> </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"> <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"> <p className="text-xs text-gray-500 flex items-center gap-1">
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
Données fournies par MarineTraffic - Mise à jour en temps réel {t('map.dataSource')}
</p> </p>
<a <a
href="https://www.marinetraffic.com" href="https://www.marinetraffic.com"
@ -499,7 +391,7 @@ export default function TrackTracePage() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1" 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" /> <ExternalLink className="h-3 w-3" />
</a> </a>
</div> </div>
@ -513,7 +405,7 @@ export default function TrackTracePage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<History className="h-5 w-5 text-gray-600" /> <History className="h-5 w-5 text-gray-600" />
Historique des recherches {t('history.title')}
</CardTitle> </CardTitle>
{searchHistory.length > 0 && ( {searchHistory.length > 0 && (
<Button <Button
@ -522,7 +414,7 @@ export default function TrackTracePage() {
onClick={handleClearHistory} onClick={handleClearHistory}
className="text-gray-500 hover:text-red-600 text-xs" className="text-gray-500 hover:text-red-600 text-xs"
> >
Effacer tout {t('history.clearAll')}
</Button> </Button>
)} )}
</div> </div>
@ -531,8 +423,8 @@ export default function TrackTracePage() {
{searchHistory.length === 0 ? ( {searchHistory.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" /> <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-sm">{t('history.empty')}</p>
<p className="text-xs text-gray-400 mt-1">Vos recherches apparaîtront ici</p> <p className="text-xs text-gray-400 mt-1">{t('history.emptyHint')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <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> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" /> <Package className="h-5 w-5 text-blue-600" />
Numéro de conteneur {t('help.containerNumber.title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">{t('help.containerNumber.description')}</p>
Format standard: 4 lettres + 7 chiffres (ex: MSKU1234567).
Le préfixe indique généralement le propriétaire du conteneur.
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -594,14 +483,11 @@ export default function TrackTracePage() {
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-600" /> <FileText className="h-5 w-5 text-blue-600" />
Connaissement (B/L) {t('help.billOfLading.title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">{t('help.billOfLading.description')}</p>
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>
</CardContent> </CardContent>
</Card> </Card>
@ -609,13 +495,11 @@ export default function TrackTracePage() {
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-blue-600" /> <ClipboardList className="h-5 w-5 text-blue-600" />
Référence de réservation {t('help.bookingRef.title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">{t('help.bookingRef.description')}</p>
Numéro de réservation attribué par le transporteur lors de la réservation initiale de l&apos;espace sur le navire.
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -625,11 +509,8 @@ export default function TrackTracePage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" /> <Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
<div> <div>
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p> <p className="text-sm font-medium text-blue-800">{t('infoBox.title')}</p>
<p className="text-sm text-blue-700 mt-1"> <p className="text-sm text-blue-700 mt-1">{t('infoBox.description')}</p>
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>
</div> </div>
</div> </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