Compare commits

..

6 Commits

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,44 +0,0 @@
/**
* DomainExceptionFilter
*
* Catches any DomainException bubbling up to the HTTP boundary, translates its
* i18nKey/i18nArgs into the caller's locale (resolved by nestjs-i18n) and
* returns a structured JSON error response.
*
* Non-domain errors fall through to NestJS's default handler.
*/
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { I18nService, I18nContext } from 'nestjs-i18n';
import { Response, Request } from 'express';
import { DomainException } from '@domain/exceptions/domain.exception';
import { DEFAULT_LOCALE, Locale, toLocale } from '@domain/value-objects/locale.vo';
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
constructor(private readonly i18n: I18nService<Record<string, unknown>>) {}
catch(exception: DomainException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const lang: Locale = toLocale(I18nContext.current()?.lang, DEFAULT_LOCALE) ?? DEFAULT_LOCALE;
const translated = this.i18n.translate(exception.i18nKey, {
lang,
args: exception.i18nArgs,
defaultValue: exception.message,
});
const status = exception.status || HttpStatus.BAD_REQUEST;
response.status(status).json({
statusCode: status,
error: exception.name,
message: typeof translated === 'string' ? translated : exception.message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,30 +0,0 @@
/**
* DomainException (Base)
*
* Base class for all translatable domain exceptions.
* Exceptions carry an i18n key + optional args so the application-layer
* exception filter can translate them into the caller's locale at the HTTP
* response boundary.
*
* Subclasses should:
* - Pass an i18nKey (e.g. 'error.PORT_NOT_FOUND')
* - Pass i18nArgs for interpolation (e.g. { portCode })
* - Optionally override `status` (HTTP status, default 400)
*/
export type I18nArgs = Record<string, string | number | boolean | undefined | null>;
export abstract class DomainException extends Error {
public readonly i18nKey: string;
public readonly i18nArgs: I18nArgs;
public readonly status: number;
constructor(i18nKey: string, i18nArgs: I18nArgs = {}, fallbackMessage?: string, status = 400) {
super(fallbackMessage ?? i18nKey);
this.i18nKey = i18nKey;
this.i18nArgs = i18nArgs;
this.status = status;
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPreferredLanguage1745000000000 implements MigrationInterface {
name = 'AddPreferredLanguage1745000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "users"
ADD COLUMN "preferred_language" VARCHAR(2) NOT NULL DEFAULT 'fr'
`);
await queryRunner.query(`
ALTER TABLE "organizations"
ADD COLUMN "default_language" VARCHAR(2) NOT NULL DEFAULT 'fr'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "organizations" DROP COLUMN "default_language"
`);
await queryRunner.query(`
ALTER TABLE "users" DROP COLUMN "preferred_language"
`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,17 @@
/**
* 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 '@/i18n/navigation'; import Link from 'next/link';
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;
@ -31,18 +33,10 @@ 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 {
alert(t('pdfNotImplemented')); // TODO: Implement PDF download functionality
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);
@ -60,12 +54,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">{t('notFound')}</h2> <h2 className="text-2xl font-semibold text-gray-900">Booking not found</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"
> >
{t('back')} Back to bookings
</Link> </Link>
</div> </div>
); );
@ -73,13 +67,14 @@ 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"
> >
{t('back')} Back to bookings
</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>
@ -88,11 +83,11 @@ export default function BookingDetailPage() {
booking.status booking.status
)}`} )}`}
> >
{getStatusLabel(booking.status)} {booking.status}
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t('createdOn', { date: new Date(booking.createdAt).toLocaleDateString(dateLocale) })} Created on {new Date(booking.createdAt).toLocaleDateString()}
</p> </p>
</div> </div>
<div className="flex space-x-3"> <div className="flex space-x-3">
@ -108,56 +103,59 @@ export default function BookingDetailPage() {
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 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>
{t('downloadPdf')} Download PDF
</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">{t('cargo.title')}</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Cargo Details</h2>
<dl className="grid grid-cols-1 gap-4"> <dl className="grid grid-cols-1 gap-4">
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('cargo.description')}</dt> <dt className="text-sm font-medium text-gray-500">Description</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.cargoDescription}</dd> <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">{t('cargo.specialInstructions')}</dt> <dt className="text-sm font-medium text-gray-500">Special Instructions</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd> <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">
{t('containers.title', { count: booking.containers?.length || 0 })} Containers ({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">{t('containers.type')}</p> <p className="text-sm font-medium text-gray-500">Type</p>
<p className="text-sm text-gray-900">{container.type}</p> <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">{t('containers.number')}</p> <p className="text-sm font-medium text-gray-500">Container Number</p>
<p className="text-sm text-gray-900">{container.containerNumber}</p> <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">{t('containers.seal')}</p> <p className="text-sm font-medium text-gray-500">Seal Number</p>
<p className="text-sm text-gray-900">{container.sealNumber}</p> <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">{t('containers.vgm')}</p> <p className="text-sm font-medium text-gray-500">VGM (kg)</p>
<p className="text-sm text-gray-900">{container.vgm}</p> <p className="text-sm text-gray-900">{container.vgm}</p>
</div> </div>
)} )}
@ -167,46 +165,47 @@ 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">{t('shipper.title')}</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Shipper</h2>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.name')}</dt> <dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="text-sm text-gray-900">{booking.shipper.name}</dd> <dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.contact')}</dt> <dt className="text-sm font-medium text-gray-500">Contact</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd> <dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.email')}</dt> <dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd> <dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.phone')}</dt> <dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactPhone}</dd> <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">{t('consignee.title')}</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Consignee</h2>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.name')}</dt> <dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="text-sm text-gray-900">{booking.consignee.name}</dd> <dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.contact')}</dt> <dt className="text-sm font-medium text-gray-500">Contact</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd> <dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.email')}</dt> <dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd> <dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.phone')}</dt> <dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd> <dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
</div> </div>
</dl> </dl>
@ -214,9 +213,11 @@ 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">{t('timeline.title')}</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Timeline</h2>
<div className="flow-root"> <div className="flow-root">
<ul className="-mb-8"> <ul className="-mb-8">
<li> <li>
@ -243,9 +244,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">{t('timeline.created')}</p> <p className="text-sm text-gray-900 font-medium">Booking Created</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleString(dateLocale)} {new Date(booking.createdAt).toLocaleString()}
</p> </p>
</div> </div>
</div> </div>
@ -256,17 +257,18 @@ 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">{t('info.title')}</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Information</h2>
<dl className="space-y-3"> <dl className="space-y-3">
<div> <div>
<dt className="text-sm font-medium text-gray-500">{t('info.bookingId')}</dt> <dt className="text-sm font-medium text-gray-500">Booking ID</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.id}</dd> <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">{t('info.lastUpdated')}</dt> <dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="mt-1 text-sm text-gray-900"> <dd className="mt-1 text-sm text-gray-900">
{new Date(booking.updatedAt).toLocaleString(dateLocale)} {new Date(booking.updatedAt).toLocaleString()}
</dd> </dd>
</div> </div>
</dl> </dl>

View File

@ -1,21 +1,22 @@
/**
* 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 { listCsvBookings } from '@/lib/api'; import { listBookings, listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation'; import Link from 'next/link';
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 { 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');
@ -30,24 +31,29 @@ 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, limit: 1000, // Fetch all bookings for client-side filtering
}), }),
}); });
// 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();
@ -64,7 +70,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(dateLocale); const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString('fr-FR');
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);
@ -77,42 +83,52 @@ 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: t('statusFilter.all') }, { value: '', label: 'Tous les statuts' },
{ value: 'PENDING', label: t('status.pending') }, { value: 'PENDING', label: 'En attente' },
{ value: 'ACCEPTED', label: t('status.accepted') }, { value: 'ACCEPTED', label: 'Accepté' },
{ value: 'REJECTED', label: t('status.rejected') }, { value: 'REJECTED', label: 'Refusé' },
]; ];
const searchTypeOptions: { value: SearchType; label: string }[] = [ const searchTypeOptions = [
{ value: 'route', label: t('searchType.route') }, { value: 'route', label: 'Route (Origine/Destination)' },
{ value: 'pallets', label: t('searchType.pallets') }, { value: 'pallets', label: 'Palettes/Colis' },
{ value: 'weight', label: t('searchType.weight') }, { value: 'weight', label: 'Poids (kg)' },
{ value: 'status', label: t('searchType.status') }, { value: 'status', label: 'Statut' },
{ value: 'date', label: t('searchType.date') }, { value: 'date', label: 'Date' },
{ value: 'quote', label: t('searchType.quote') }, { value: 'quote', label: 'N° Devis' },
]; ];
const getPlaceholder = () => { const getPlaceholder = () => {
const keyMap: Record<SearchType, string> = { switch (searchType) {
route: 'searchPlaceholder.route', case 'pallets':
pallets: 'searchPlaceholder.pallets', return 'Rechercher par nombre de palettes...';
weight: 'searchPlaceholder.weight', case 'weight':
status: 'searchPlaceholder.status', return 'Rechercher par poids en kg...';
date: 'searchPlaceholder.date', case 'route':
quote: 'searchPlaceholder.quote', return 'Rechercher par ville (origine ou destination)...';
}; case 'status':
return t(keyMap[searchType] as any); return 'Rechercher par statut...';
case 'date':
return 'Rechercher par date (JJ/MM/AAAA)...';
case 'quote':
return 'Rechercher par numéro de devis...';
default:
return 'Rechercher...';
}
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@ -125,66 +141,76 @@ export default function BookingsListPage() {
}; };
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const map: Record<string, string> = { const labels: Record<string, string> = {
PENDING: t('status.pending'), PENDING: 'En attente',
ACCEPTED: t('status.accepted'), ACCEPTED: 'Accepté',
REJECTED: t('status.rejected'), REJECTED: 'Refusé',
}; };
return map[status] || status; return labels[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">{t('transferBanner.title')}</p> <p className="font-medium text-amber-800">Virement déclaré</p>
<p className="text-sm text-amber-700 mt-0.5"> <p className="text-sm text-amber-700 mt-0.5">
{t('transferBanner.message')} Votre virement a é enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
</p> </p>
</div> </div>
</div> </div>
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button> <button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button>
</div> </div>
)} )}
<PageHeader {/* Header */}
title={t('title')} <div className="flex items-center justify-between">
description={t('description')} <div>
actions={ <h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
<> <p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
<ExportButton </div>
data={filteredBookings} <div className="flex items-center space-x-3">
filename={t('exportFilename')} <ExportButton
columns={[ data={filteredBookings}
{ key: 'id', label: t('export.id') }, filename="reservations"
{ key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` }, columns={[
{ key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` }, { key: 'id', label: 'ID' },
{ key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` }, { key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
{ key: 'origin', label: t('export.origin') }, { key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
{ key: 'destination', label: t('export.destination') }, { key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
{ key: 'carrierName', label: t('export.carrier') }, { key: 'origin', label: 'Origine' },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) }, { key: 'destination', label: 'Destination' },
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' }, { key: 'carrierName', label: 'Transporteur' },
]} { key: 'status', label: 'Statut', format: (v) => {
/> const labels: Record<string, string> = {
<Link PENDING: 'En attente',
href="/dashboard/search-advanced" ACCEPTED: 'Accepté',
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" REJECTED: 'Refusé',
> };
<Plus className="h-4 w-4 sm:mr-2" /> return labels[v] || v;
<span className="hidden sm:inline">{t('new')}</span> }},
</Link> { key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
</> ]}
} />
/> <Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>
</div>
{/* 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">
{t('searchType.label')} Type de recherche
</label> </label>
<select <select
id="searchType" id="searchType"
@ -204,7 +230,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">
{t('search')} Rechercher
</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">
@ -237,7 +263,7 @@ export default function BookingsListPage() {
</div> </div>
<div> <div>
<label htmlFor="status" className="sr-only"> <label htmlFor="status" className="sr-only">
{t('statusFilter.label')} Statut
</label> </label>
<select <select
id="status" id="status"
@ -258,91 +284,39 @@ export default function BookingsListPage() {
</div> </div>
</div> </div>
{/* Bookings Table */}
<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>
{t('loading')} Chargement des réservations...
</div> </div>
) : paginatedBookings && paginatedBookings.length > 0 ? ( ) : paginatedBookings && paginatedBookings.length > 0 ? (
<> <>
<div className="md:hidden divide-y divide-gray-200"> <div className="overflow-x-auto">
{paginatedBookings.map((booking: any) => (
<div key={`${booking.type}-${booking.id}`} className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold text-gray-900">
{booking.type === 'csv'
? `${booking.origin}${booking.destination}`
: booking.route || 'N/A'}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
</div>
</div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}>
{getStatusLabel(booking.status)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.pallets')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.palletsShort', { count: booking.palletCount })
: t('units.containersShort', { count: booking.containers?.length || 0 })}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.weight')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight ? t('units.kg', { value: booking.totalWeight }) : 'N/A'}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.date')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: '2-digit' })
: 'N/A'}
</div>
</div>
</div>
<div className="text-xs text-gray-400">
{booking.type === 'csv'
? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() })
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
</div>
))}
</div>
<div className="hidden md:block overflow-x-auto">
<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('columns.palletsPackages')} Palettes/Colis
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.weight')} Poids
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.route')} Route
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.status')} Statut
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.date')} 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">
{t('columns.quoteNumber')} N° Devis
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.bookingNumber')} N° Booking
</th> </th>
</tr> </tr>
</thead> </thead>
@ -352,8 +326,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'
? t('units.palletsCount', { count: booking.palletCount }) ? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
: t('units.containersCount', { count: booking.containers?.length || 0 })} : `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
</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'}
@ -362,16 +336,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'
? t('units.kg', { value: booking.weightKG }) ? `${booking.weightKG} kg`
: booking.totalWeight : booking.totalWeight
? t('units.kg', { value: booking.totalWeight }) ? `${booking.totalWeight} kg`
: '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'
? t('units.cbm', { value: booking.volumeCBM }) ? `${booking.volumeCBM} CBM`
: booking.totalVolume : booking.totalVolume
? t('units.cbm', { value: booking.totalVolume }) ? `${booking.totalVolume} CBM`
: ''} : ''}
</div> </div>
</td> </td>
@ -398,7 +372,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(dateLocale, { ? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString('fr-FR', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
@ -419,6 +393,7 @@ 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">
@ -427,25 +402,22 @@ 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"
> >
{t('pagination.previous')} Précédent
</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"
> >
{t('pagination.next')} Suivant
</button> </button>
</div> </div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
{t.rich('pagination.showing', { Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
start: startIndex + 1, <span className="font-medium">{Math.min(endIndex, totalBookings)}</span> sur{' '}
end: Math.min(endIndex, totalBookings), <span className="font-medium">{totalBookings}</span> résultat{totalBookings > 1 ? 's' : ''}
total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>,
})}
</p> </p>
</div> </div>
<div> <div>
@ -455,14 +427,16 @@ 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">{t('pagination.previous')}</span> <span className="sr-only">Précédent</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
</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);
@ -495,7 +469,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">{t('pagination.next')}</span> <span className="sr-only">Suivant</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg> </svg>
@ -521,11 +495,11 @@ export default function BookingsListPage() {
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" 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">{t('empty.title')}</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">Aucune réservation trouvée</h3>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter {searchTerm || statusFilter
? t('empty.hasFilters') ? 'Essayez d\'ajuster vos filtres'
: t('empty.noBookings')} : 'Commencez par créer votre première réservation'}
</p> </p>
<div className="mt-6"> <div className="mt-6">
<Link <Link
@ -533,7 +507,7 @@ export default function BookingsListPage() {
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" 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" />
{t('new')} Nouvelle Réservation
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -1,12 +1,10 @@
'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';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document { interface Document {
id: string; id: string;
@ -16,6 +14,7 @@ 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,8 +29,6 @@ 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);
@ -42,27 +39,41 @@ 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', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', pdf: 'PDF',
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV', doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
}; };
return typeMap[ext] || ext.toUpperCase(); return typeMap[ext] || ext.toUpperCase();
}; };
@ -70,19 +81,23 @@ 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('/');
@ -113,16 +128,17 @@ export default function UserDocumentsPage() {
setDocuments(allDocuments); setDocuments(allDocuments);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('error')); setError(err.message || 'Erreur lors du chargement des documents');
} 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 === '' ||
@ -140,11 +156,13 @@ 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]);
@ -187,12 +205,18 @@ export default function UserDocumentsPage() {
}; };
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; const labels: Record<string, string> = {
return t(`statuses.${key}` as any) || status; PENDING: 'En attente',
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;
@ -202,10 +226,18 @@ 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, { mode: 'cors', credentials: 'include' }); const response = await fetch(url, {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); mode: 'cors',
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');
@ -219,12 +251,15 @@ export default function UserDocumentsPage() {
console.error('Fetch download failed:', fetchError); console.error('Fetch download failed:', fetchError);
} }
}, 100); }, 100);
} catch (err) { } catch (error) {
console.error('Error downloading file:', err); console.error('Error downloading file:', error);
alert(`${t('downloadError')}: ${err instanceof Error ? err.message : ''}`); alert(
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
} }
}; };
// Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsAvailableForDocuments = bookings.filter( const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED' b => b.status === 'PENDING' || b.status === 'ACCEPTED'
); );
@ -236,12 +271,14 @@ export default function UserDocumentsPage() {
const handleCloseModal = () => { const handleCloseModal = () => {
setShowAddModal(false); setShowAddModal(false);
setSelectedBookingId(null); setSelectedBookingId(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}; };
const handleFileUpload = async () => { const handleFileUpload = async () => {
if (!selectedBookingId || !fileInputRef.current?.files?.length) { if (!selectedBookingId || !fileInputRef.current?.files?.length) {
alert(t('addDocument.noBookingError')); alert('Veuillez sélectionner une réservation et au moins un fichier');
return; return;
} }
@ -256,34 +293,50 @@ 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) throw new Error(t('addDocument.errorMessage')); if (!response.ok) {
throw new Error('Erreur lors de l\'ajout des documents');
}
alert(t('addDocument.successMessage')); alert('Documents ajoutés avec succès!');
handleCloseModal(); handleCloseModal();
fetchBookingsAndDocuments(); fetchBookingsAndDocuments(); // Refresh the list
} catch (err) { } catch (error) {
console.error('Error uploading documents:', err); console.error('Error uploading documents:', error);
alert(`${t('addDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`); alert(
`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 = () => setOpenDropdownId(null); const handleClickOutside = () => {
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);
@ -293,12 +346,14 @@ export default function UserDocumentsPage() {
const handleCloseReplaceModal = () => { const handleCloseReplaceModal = () => {
setShowReplaceModal(false); setShowReplaceModal(false);
setDocumentToReplace(null); setDocumentToReplace(null);
if (replaceFileInputRef.current) replaceFileInputRef.current.value = ''; if (replaceFileInputRef.current) {
replaceFileInputRef.current.value = '';
}
}; };
const handleReplaceDocument = async () => { const handleReplaceDocument = async () => {
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) { if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
alert(t('replaceDocument.noFileError')); alert('Veuillez sélectionner un fichier de remplacement');
return; return;
} }
@ -310,20 +365,28 @@ 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 || t('replaceDocument.errorMessage')); throw new Error(errorData.message || 'Erreur lors du remplacement du document');
} }
alert(t('replaceDocument.successMessage')); alert('Document remplacé avec succès!');
handleCloseReplaceModal(); handleCloseReplaceModal();
fetchBookingsAndDocuments(); fetchBookingsAndDocuments(); // Refresh the list
} catch (err) { } catch (error) {
console.error('Error replacing document:', err); console.error('Error replacing document:', error);
alert(`${t('replaceDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`); alert(
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
} finally { } finally {
setReplacingFile(false); setReplacingFile(false);
} }
@ -334,7 +397,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">{t('loading')}</p> <p className="mt-4 text-gray-600">Chargement des documents...</p>
</div> </div>
</div> </div>
); );
@ -342,52 +405,68 @@ export default function UserDocumentsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader {/* Header */}
title={t('title')} <div className="flex items-center justify-between">
description={t('description')} <div>
actions={ <h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
<> <p className="mt-1 text-sm text-gray-500">
<ExportButton Gérez tous les documents de vos réservations
data={filteredDocuments} </p>
filename="documents" </div>
columns={[ <div className="flex items-center space-x-3">
{ key: 'fileName', label: t('export.fileName') }, <ExportButton
{ key: 'fileType', label: t('export.type') }, data={filteredDocuments}
{ key: 'quoteNumber', label: t('export.quoteNumber') }, filename="documents"
{ key: 'route', label: t('export.route') }, columns={[
{ key: 'carrierName', label: t('export.carrier') }, { key: 'fileName', label: 'Nom du fichier' },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) }, { key: 'fileType', label: 'Type' },
{ key: 'uploadedAt', label: t('export.uploadedAt'), format: (v) => v ? new Date(v).toLocaleDateString(locale) : '' }, { key: 'quoteNumber', label: 'N° de Devis' },
]} { key: 'route', label: 'Route' },
/> { key: 'carrierName', label: 'Transporteur' },
<button { key: 'status', label: 'Statut', format: (v) => {
onClick={handleAddDocumentClick} const labels: Record<string, string> = {
disabled={bookingsAvailableForDocuments.length === 0} PENDING: 'En attente',
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" ACCEPTED: 'Accepté',
> REJECTED: 'Refusé',
<svg className="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> CANCELLED: 'Annulé',
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> };
</svg> return labels[v] || v;
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span> }},
</button> { key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
</> ]}
} />
/> <button
onClick={handleAddDocumentClick}
disabled={bookingsAvailableForDocuments.length === 0}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Ajouter un document
</button>
</div>
</div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.total')}</div> <div className="text-sm text-gray-500">Total Documents</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div> <div className="text-2xl font-bold text-gray-900">{documents.length}</div>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.withDocuments')}</div> <div className="text-sm text-gray-500">Réservations avec Documents</div>
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length} {bookings.filter(b => b.documents && b.documents.length > 0).length}
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.filtered')}</div> <div className="text-sm text-gray-500">Documents Filtrés</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div> <div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div> </div>
</div> </div>
@ -396,17 +475,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">{t('filters.search')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
<input <input
type="text" type="text"
placeholder={t('filters.searchPlaceholder')} placeholder="Nom, type, route, transporteur..."
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">{t('filters.quoteNumber')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">Numéro de Devis</label>
<input <input
type="text" type="text"
placeholder="Ex: #F2CAD5E1" placeholder="Ex: #F2CAD5E1"
@ -416,16 +495,16 @@ export default function UserDocumentsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<select <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">{t('filters.allStatuses')}</option> <option value="all">Tous les statuts</option>
<option value="PENDING">{t('statuses.PENDING')}</option> <option value="PENDING">En attente</option>
<option value="ACCEPTED">{t('statuses.ACCEPTED')}</option> <option value="ACCEPTED">Accepté</option>
<option value="REJECTED">{t('statuses.REJECTED')}</option> <option value="REJECTED">Refusé</option>
</select> </select>
</div> </div>
</div> </div>
@ -443,20 +522,36 @@ 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">{t('table.documentName')}</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.type')}</th> Nom du Document
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.quoteNumber')}</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.route')}</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.carrier')}</th> Type
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th> </th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.actions')}</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
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 ? t('empty.noDocuments') : t('empty.noMatch')} {documents.length === 0
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
: 'Aucun document ne correspond aux filtres sélectionnés.'}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -467,7 +562,9 @@ 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">{getDocumentIcon(doc.fileType || doc.type)}</span> <span className="mr-2">
{getDocumentIcon(doc.fileType || doc.type)}
</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div> <div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div> </div>
</td> </td>
@ -481,7 +578,9 @@ 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 className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}> <span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
>
{getStatusLabel(doc.status)} {getStatusLabel(doc.status)}
</span> </span>
</td> </td>
@ -493,15 +592,20 @@ 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={t('table.actions')} title="Actions"
> >
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<circle cx="12" cy="5" r="2" /> <circle cx="12" cy="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"
@ -515,19 +619,39 @@ export default function UserDocumentsPage() {
}} }}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
> >
<svg className="w-4 h-4 mr-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> className="w-4 h-4 mr-3 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg> </svg>
{t('actions.download')} Télécharger
</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 className="w-4 h-4 mr-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> className="w-4 h-4 mr-3 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
{t('actions.replace')} Remplacer
</button> </button>
</div> </div>
</div> </div>
@ -549,29 +673,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"
> >
{t('pagination.previous')} Précédent
</button> </button>
<button <button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))} onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t('pagination.next')} Suivant
</button> </button>
</div> </div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
{t('pagination.showing', { Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
from: startIndex + 1, <span className="font-medium">
to: Math.min(endIndex, filteredDocuments.length), {Math.min(endIndex, filteredDocuments.length)}
total: filteredDocuments.length, </span>{' '}
})} 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">{t('pagination.perPage')}</label> <label className="text-sm text-gray-700">Par page:</label>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={e => { onChange={e => {
@ -587,18 +711,26 @@ export default function UserDocumentsPage() {
<option value={100}>100</option> <option value={100}>100</option>
</select> </select>
</div> </div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> <nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button <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">{t('pagination.previous')}</span> <span className="sr-only">Précédent</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</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) {
@ -631,9 +763,13 @@ 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">{t('pagination.next')}</span> <span className="sr-only">Suivant</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg> </svg>
</button> </button>
</nav> </nav>
@ -647,35 +783,57 @@ 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">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseModal} /> {/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseModal}
/>
{/* Modal panel */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="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 className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </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">{t('addDocument.modalTitle')}</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
Ajouter un document
</h3>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
Sélectionner une réservation
</label>
<select <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="">{t('addDocument.selectBookingPlaceholder')}</option> <option value="">-- Choisir une réservation --</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' ? t('statuses.PENDING') : t('statuses.ACCEPTED')}) {getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
Fichiers à ajouter
</label>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -683,7 +841,9 @@ 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">{t('addDocument.acceptedFormats')}</p> <p className="mt-1 text-xs text-gray-500">
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
</p>
</div> </div>
</div> </div>
</div> </div>
@ -698,20 +858,37 @@ export default function UserDocumentsPage() {
> >
{uploadingFiles ? ( {uploadingFiles ? (
<> <>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
<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" /> fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg> </svg>
{t('addDocument.uploading')} Envoi en cours...
</> </>
) : 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"
> >
{t('addDocument.cancel')} Annuler
</button> </button>
</div> </div>
</div> </div>
@ -723,34 +900,60 @@ 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">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseReplaceModal} /> {/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseReplaceModal}
/>
{/* Modal panel */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="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 className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </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">{t('replaceDocument.modalTitle')}</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
Remplacer le document
</h3>
<div className="mt-4 space-y-4"> <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">{t('replaceDocument.currentDocument')}</p> <p className="text-sm text-gray-500">Document actuel:</p>
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p> <p className="text-sm font-medium text-gray-900 mt-1">
{documentToReplace.fileName}
</p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route} Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</label> <label className="block text-sm font-medium text-gray-700 mb-2">
Nouveau fichier
</label>
<input <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">{t('replaceDocument.acceptedFormats')}</p> <p className="mt-1 text-xs text-gray-500">
Formats acceptés: PDF, Word, Excel, Images
</p>
</div> </div>
</div> </div>
</div> </div>
@ -765,20 +968,37 @@ export default function UserDocumentsPage() {
> >
{replacingFile ? ( {replacingFile ? (
<> <>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
<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" /> fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg> </svg>
{t('replaceDocument.replacing')} Remplacement en cours...
</> </>
) : 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"
> >
{t('replaceDocument.cancel')} Annuler
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,11 +1,16 @@
/**
* 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, usePathname, useRouter } from '@/i18n/navigation'; import Link from 'next/link';
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 {
@ -19,8 +24,6 @@ import {
LogOut, LogOut,
Lock, Lock,
Key, Key,
Home,
User,
} from 'lucide-react'; } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context'; import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge'; import StatusBadge from '@/components/ui/StatusBadge';
@ -31,7 +34,6 @@ 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(() => {
@ -53,15 +55,16 @@ 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: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' }, { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package }, { name: 'Réservations', href: '/dashboard/bookings', icon: Package },
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText }, { name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' }, { name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' }, { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 }, { name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
{ name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature }, { name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ ...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: t('nav.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature }, { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
] : []), ] : []),
]; ];
@ -74,6 +77,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return ( 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"
@ -81,12 +85,14 @@ 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
@ -113,6 +119,7 @@ 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);
@ -135,6 +142,7 @@ 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 />
@ -142,6 +150,7 @@ 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">
@ -165,16 +174,18 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors" 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" />
{t('logout')} Déconnexion
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Main content */}
<div className="lg:pl-64"> <div className="lg:pl-64">
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b"> {/* Top bar */}
<div className="sticky top-0 z-10 flex items-center justify-between h-16 px-6 bg-white border-b">
<button <button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1" className="lg:hidden text-gray-500 hover:text-gray-700"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -187,48 +198,24 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</svg> </svg>
</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-xl font-semibold text-gray-900">
{navigation.find(item => isActive(item.href))?.name || t('topbar.defaultTitle')} {navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
</h1> </h1>
</div> </div>
<div className="flex items-center space-x-3 lg:space-x-4"> <div className="flex items-center space-x-4">
<LanguageSwitcher variant="light" /> {/* Notifications */}
<NotificationDropdown /> <NotificationDropdown />
<Link href="/dashboard/profile" className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors"> {/* User Initials */}
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
{user?.firstName?.[0]}{user?.lastName?.[0]} {user?.firstName?.[0]}{user?.lastName?.[0]}
</Link> </Link>
</div> </div>
</div> </div>
<main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main> {/* Page content */}
<main className="p-6">{children}</main>
</div> </div>
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-gray-200 lg:hidden">
<div className="grid grid-cols-5 h-16">
{[
{ href: '/dashboard', icon: Home, label: t('bottomNav.home') },
{ href: '/dashboard/bookings', icon: Package, label: t('bottomNav.bookings') },
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map((item) => {
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center space-y-0.5 transition-colors ${
active ? 'text-blue-600' : 'text-gray-500 hover:text-gray-700'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div> </div>
); );
} }

View File

@ -1,9 +1,14 @@
/**
* 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,
@ -15,14 +20,12 @@ 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: () =>
@ -38,6 +41,7 @@ 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: () => {
@ -45,6 +49,7 @@ export default function NotificationsPage() {
}, },
}); });
// Mark all as read
const markAllAsReadMutation = useMutation({ const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead, mutationFn: markAllNotificationsAsRead,
onSuccess: () => { onSuccess: () => {
@ -52,6 +57,7 @@ export default function NotificationsPage() {
}, },
}); });
// Delete notification
const deleteNotificationMutation = useMutation({ const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification, mutationFn: deleteNotification,
onSuccess: () => { onSuccess: () => {
@ -64,6 +70,7 @@ 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);
} }
@ -71,7 +78,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(t('deleteConfirm'))) { if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
deleteNotificationMutation.mutate(notificationId); deleteNotificationMutation.mutate(notificationId);
} }
}; };
@ -86,16 +93,6 @@ export default function NotificationsPage() {
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100'; 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> = {
@ -123,11 +120,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 t('time.now'); if (diffMins < 1) return 'A l\'instant';
if (diffMins < 60) return t('time.minutes', { count: diffMins }); if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return t('time.hours', { count: diffHours }); if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return t('time.days', { count: diffDays }); if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString(dateLocale, { return date.toLocaleDateString('fr-FR', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -138,6 +135,7 @@ 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">
@ -146,10 +144,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">{t('title')}</h1> <h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
{t('totalLabel', { count: total })} {total} notification{total !== 1 ? 's' : ''} au total
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })} {unreadCount > 0 && `${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
</p> </p>
</div> </div>
</div> </div>
@ -160,18 +158,20 @@ export default function NotificationsPage() {
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors" 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>{t('markAllRead')}</span> <span>Tout marquer comme lu</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">{t('filter.label')}</span> <span className="text-sm font-medium text-gray-700">Filtrer :</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'
}`} }`}
> >
{t(`filter.${filter}` as any)} {filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{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,21 +198,24 @@ 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">{t('loading')}</p> <p className="text-gray-500">Chargement des notifications...</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">{t('empty.title')}</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
<p className="text-gray-500"> <p className="text-gray-500">
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')} {selectedFilter === 'unread'
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
</p> </p>
</div> </div>
</div> </div>
@ -227,10 +230,12 @@ 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">
@ -240,14 +245,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>{t('new')}</span> <span>NOUVEAU</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={t('deleteTitle')} title="Supprimer la notification"
> >
<Trash2 className="w-5 h-5 text-red-600" /> <Trash2 className="w-5 h-5 text-red-600" />
</button> </button>
@ -257,6 +262,7 @@ 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">
@ -290,13 +296,13 @@ export default function NotificationsPage() {
: 'bg-blue-100 text-blue-700' : 'bg-blue-100 text-blue-700'
}`} }`}
> >
{getPriorityLabel(notification.priority)} {notification.priority.toUpperCase()}
</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>{t('viewDetails')}</span> <span>Voir les détails</span>
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
@ -321,16 +327,15 @@ 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">
{t.rich('pagination.info', { Page <span className="font-semibold">{currentPage}</span> sur{' '}
current: currentPage, <span className="font-semibold">{totalPages}</span>
total: totalPages, {' • '}
items: total, <span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au 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
@ -339,7 +344,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 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>{t('pagination.previous')}</span> <span>Précédent</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) => {
@ -374,7 +379,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>{t('pagination.next')}</span> <span>Suivant</span>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>

View File

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

View File

@ -1,3 +1,9 @@
/**
* 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';
@ -6,44 +12,45 @@ 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('');
const passwordSchema = z // Profile form
.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: {
@ -53,6 +60,7 @@ export default function ProfilePage() {
}, },
}); });
// Password form
const passwordForm = useForm<PasswordFormData>({ const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema), resolver: zodResolver(passwordSchema),
defaultValues: { defaultValues: {
@ -62,6 +70,7 @@ export default function ProfilePage() {
}, },
}); });
// Update form values when user data loads
useEffect(() => { useEffect(() => {
if (user) { if (user) {
profileForm.reset({ profileForm.reset({
@ -73,6 +82,7 @@ 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({
@ -84,24 +94,26 @@ 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(t('profileForm.successUpdate')); setSuccessMessage('Profil mis à jour avec succès !');
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 || t('profileForm.errorUpdate')); setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setSuccessMessage(''); setSuccessMessage('');
}, },
}); });
// Update password mutation
const updatePasswordMutation = useMutation({ const updatePasswordMutation = useMutation({
mutationFn: async (data: PasswordFormData) => { mutationFn: async (data: PasswordFormData) => {
return changePassword({ return changePassword({
@ -110,7 +122,7 @@ export default function ProfilePage() {
}); });
}, },
onSuccess: () => { onSuccess: () => {
setSuccessMessage(t('passwordForm.successUpdate')); setSuccessMessage('Mot de passe mis à jour avec succès !');
setErrorMessage(''); setErrorMessage('');
passwordForm.reset({ passwordForm.reset({
currentPassword: '', currentPassword: '',
@ -120,7 +132,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000); setTimeout(() => setSuccessMessage(''), 3000);
}, },
onError: (error: any) => { onError: (error: any) => {
setErrorMessage(error.message || t('passwordForm.errorUpdate')); setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
setSuccessMessage(''); setSuccessMessage('');
}, },
}); });
@ -133,27 +145,29 @@ 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">{t('loading')}</p> <p className="text-gray-600">Chargement du profil...</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">{t('loadError')}</p> <p className="text-red-600 mb-4">Impossible de charger le profil</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"
> >
{t('retry')} Réessayer
</button> </button>
</div> </div>
</div> </div>
@ -162,11 +176,13 @@ 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">{t('header.title')}</h1> <h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
<p className="text-blue-100">{t('header.subtitle')}</p> <p className="text-blue-100">Gérez vos paramètres de compte et préférences</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">
@ -197,6 +213,7 @@ 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">
@ -213,13 +230,14 @@ 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">
{t('active')} Actif
</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">
@ -231,7 +249,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'
}`} }`}
> >
{t('tabs.profile')} Informations personnelles
</button> </button>
<button <button
onClick={() => setActiveTab('password')} onClick={() => setActiveTab('password')}
@ -241,7 +259,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'
}`} }`}
> >
{t('tabs.password')} Modifier le mot de passe
</button> </button>
</nav> </nav>
</div> </div>
@ -250,9 +268,13 @@ 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 htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2"> <label
{t('profileForm.firstName')} htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prénom
</label> </label>
<input <input
{...profileForm.register('firstName')} {...profileForm.register('firstName')}
@ -267,9 +289,13 @@ export default function ProfilePage() {
)} )}
</div> </div>
{/* Last Name */}
<div> <div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2"> <label
{t('profileForm.lastName')} htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nom
</label> </label>
<input <input
{...profileForm.register('lastName')} {...profileForm.register('lastName')}
@ -285,9 +311,10 @@ 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">
{t('profileForm.email')} Adresse email
</label> </label>
<input <input
{...profileForm.register('email')} {...profileForm.register('email')}
@ -296,24 +323,29 @@ 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">{t('profileForm.emailHelp')}</p> <p className="mt-1 text-xs text-gray-500">L&apos;adresse email ne peut pas être modifiée</p>
</div> </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 ? t('profileForm.saving') : t('profileForm.save')} {updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
</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 htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2"> <label
{t('passwordForm.current')} htmlFor="currentPassword"
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')}
@ -329,9 +361,13 @@ export default function ProfilePage() {
)} )}
</div> </div>
{/* New Password */}
<div> <div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2"> <label
{t('passwordForm.new')} htmlFor="newPassword"
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')}
@ -345,12 +381,18 @@ 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">{t('passwordForm.newHint')}</p> <p className="mt-1 text-xs text-gray-500">
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
</p>
</div> </div>
{/* Confirm Password */}
<div> <div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2"> <label
{t('passwordForm.confirm')} htmlFor="confirmPassword"
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')}
@ -366,13 +408,14 @@ 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 ? t('passwordForm.submitting') : t('passwordForm.submit')} {updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
</button> </button>
</div> </div>
</form> </form>

View File

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

View File

@ -1,9 +1,7 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation'; import { useRouter, 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';
@ -15,15 +13,13 @@ 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');
@ -53,11 +49,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 : t('errorGeneric')); setError(err instanceof Error ? err.message : 'Une erreur est survenue lors de la recherche');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]); }, [origin, destination, volumeCBM, weightKG, palletCount, searchParams]);
useEffect(() => { useEffect(() => {
if (!origin || !destination || !volumeCBM || !weightKG) { if (!origin || !destination || !volumeCBM || !weightKG) {
@ -71,10 +67,12 @@ 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,
@ -83,6 +81,7 @@ export default function SearchResultsPage() {
}; };
} }
// Fallback: if serviceLevel is not present (old endpoint), use sorting
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR); const 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);
@ -96,7 +95,7 @@ export default function SearchResultsPage() {
const bestOptions = getBestOptions(); const bestOptions = getBestOptions();
const formatPrice = (price: number) => { const formatPrice = (price: number) => {
return new Intl.NumberFormat(dateLocale, { return new Intl.NumberFormat('fr-FR', {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
}).format(price); }).format(price);
@ -108,7 +107,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">{t('loadingTitle')}</p> <p className="text-xl text-gray-700 font-medium">Recherche des meilleurs tarifs en cours...</p>
<p className="text-gray-500 mt-2"> <p className="text-gray-500 mt-2">
{origin} {destination} {origin} {destination}
</p> </p>
@ -124,13 +123,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">{t('errorTitle')}</h3> <h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700 mb-4">{error}</p> <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"
> >
{t('backToSearch')} Retour à la recherche
</button> </button>
</div> </div>
</div> </div>
@ -146,30 +145,35 @@ 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"
> >
{t('backToSearch')} Retour à la recherche
</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">{t('noResultsTitle')}</h3> <h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
{t('noResultsMessage', { origin, destination })} Aucun tarif ne correspond à votre recherche pour le trajet {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"> <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>
<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>{t('suggestionPorts')}</li> <li>
<li>{t('suggestionVolume')}</li> <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX,
<li>{t('suggestionWeight')}</li> CNSHG, SGSIN (destination)
</li>
<li>
<strong>Volume :</strong> Essayez entre 1 et 200 CBM
</li>
<li>
<strong>Poids :</strong> Essayez entre 100 et 30000 kg
</li>
</ul> </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"
> >
{t('modifySearch')} Modifier la recherche
</button> </button>
</div> </div>
</div> </div>
@ -179,7 +183,7 @@ export default function SearchResultsPage() {
const optionCards = [ const optionCards = [
{ {
type: t('options.economic'), type: 'Économique',
option: bestOptions?.eco, option: bestOptions?.eco,
colors: { colors: {
border: 'border-green-200', border: 'border-green-200',
@ -188,10 +192,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: t('options.badgeCheapest'), badge: 'Le moins cher',
}, },
{ {
type: t('options.standard'), type: 'Standard',
option: bestOptions?.standard, option: bestOptions?.standard,
colors: { colors: {
border: 'border-blue-200', border: 'border-blue-200',
@ -200,10 +204,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: t('options.badgeBalanced'), badge: 'Équilibré',
}, },
{ {
type: t('options.fast'), type: 'Rapide',
option: bestOptions?.fast, option: bestOptions?.fast,
colors: { colors: {
border: 'border-purple-200', border: 'border-purple-200',
@ -212,7 +216,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: t('options.badgeFastest'), badge: 'Le plus rapide',
}, },
]; ];
@ -225,23 +229,21 @@ 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"
> >
{t('backToSearch')} Retour à la recherche
</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">{t('resultsTitle')}</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">Résultats de recherche</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 > 0 && `${palletCount} palette${palletCount > 1 ? 's' : ''}`}
? 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">{t('ratesFound')}</p> <p className="text-sm text-gray-500">Tarifs trouvés</p>
<p className="text-3xl font-bold text-blue-600">{results.length}</p> <p className="text-3xl font-bold text-blue-600">{results.length}</p>
</div> </div>
</div> </div>
@ -253,7 +255,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" />
{t('bestChoices')} Meilleurs choix pour votre recherche
</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">
@ -280,23 +282,21 @@ 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">{t('totalPrice')}</p> <p className="text-sm text-gray-600 mb-1">Prix total</p>
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p> <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">{t('carrier')}</span> <span className="text-gray-600">Transporteur :</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">{t('transit')}</span> <span className="text-gray-600">Transit :</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
{t('transitDays', { days: card.option.transitDays })}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('type')}</span> <span className="text-gray-600">Type :</span>
<span className="font-semibold text-gray-900">{card.option.containerType}</span> <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`}
> >
{t('select')} Sélectionner cette option
</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">{t('allResults', { count: results.length })}</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">Tous les tarifs disponibles ({results.length})</h2>
<div className="space-y-4"> <div className="space-y-4">
{results.map((result, index) => ( {results.map((result, index) => (
@ -335,45 +335,39 @@ 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">{t('totalPrice')}</p> <p className="text-sm text-gray-500">Prix total</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">{t('priceBreakdown.base')}</p> <p className="text-xs text-gray-600 mb-1">Prix de 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">{t('priceBreakdown.volume')}</p> <p className="text-xs text-gray-600 mb-1">Frais 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">{t('priceBreakdown.weight')}</p> <p className="text-xs text-gray-600 mb-1">Frais poids</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">{t('priceBreakdown.transit')}</p> <p className="text-xs text-gray-600 mb-1">Délai transit</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">{result.transitDays} jours</p>
{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>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span> <span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
{result.hasSurcharges && ( {result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
<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={() => {
@ -382,7 +376,7 @@ export default function SearchResultsPage() {
}} }}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
{t('selectShort')} Sélectionner
</button> </button>
</div> </div>
</div> </div>

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