Compare commits

..

No commits in common. "9bed6b54a7cecf21def66b614ea2096235c74db9" and "94039598d90bc70c205a74fb2733abe3328cbba7" have entirely different histories.

64 changed files with 1938 additions and 5116 deletions

1095
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,7 @@
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service';
import {
CarrierDocumentsResponseDto,
VerifyDocumentAccessDto,
DocumentAccessRequirementsDto,
} from '../dto/carrier-documents.dto';
/**
* CSV Booking Actions Controller (Public Routes)
@ -93,84 +88,4 @@ export class CsvBookingActionsController {
reason: reason || null,
};
}
/**
* Check document access requirements (PUBLIC - token-based)
*
* GET /api/v1/csv-booking-actions/documents/:token/requirements
*/
@Public()
@Get('documents/:token/requirements')
@ApiOperation({
summary: 'Check document access requirements (public)',
description:
'Check if a password is required to access booking documents. Use this before showing the password form.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Access requirements retrieved successfully.',
type: DocumentAccessRequirementsDto,
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
async getDocumentAccessRequirements(
@Param('token') token: string
): Promise<DocumentAccessRequirementsDto> {
return this.csvBookingService.checkDocumentAccessRequirements(token);
}
/**
* Get booking documents for carrier with password verification (PUBLIC - token-based)
*
* POST /api/v1/csv-booking-actions/documents/:token
*/
@Public()
@Post('documents/:token')
@ApiOperation({
summary: 'Get booking documents with password (public)',
description:
'Public endpoint for carriers to access booking documents after acceptance. Requires password verification. Returns booking summary and documents with signed download URLs.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiBody({ type: VerifyDocumentAccessDto })
@ApiResponse({
status: 200,
description: 'Booking documents retrieved successfully.',
type: CarrierDocumentsResponseDto,
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
@ApiResponse({ status: 401, description: 'Invalid password' })
async getBookingDocumentsWithPassword(
@Param('token') token: string,
@Body() dto: VerifyDocumentAccessDto
): Promise<CarrierDocumentsResponseDto> {
return this.csvBookingService.getDocumentsForCarrier(token, dto.password);
}
/**
* Get booking documents for carrier (PUBLIC - token-based) - Legacy without password
* Kept for backward compatibility with bookings created before password protection
*
* GET /api/v1/csv-booking-actions/documents/:token
*/
@Public()
@Get('documents/:token')
@ApiOperation({
summary: 'Get booking documents (public) - Legacy',
description:
'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking documents retrieved successfully.',
type: CarrierDocumentsResponseDto,
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
@ApiResponse({ status: 401, description: 'Password required for this booking' })
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
return this.csvBookingService.getDocumentsForCarrier(token);
}
}

View File

@ -14,20 +14,13 @@ import {
HttpCode,
HttpStatus,
Res,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Response, Request } from 'express';
import { Response } from 'express';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator';
import { UserPayload } from '../decorators/current-user.decorator';
import { GDPRService } from '../services/gdpr.service';
import {
UpdateConsentDto,
ConsentResponseDto,
WithdrawConsentDto,
ConsentSuccessDto,
} from '../dto/consent.dto';
import { GDPRService, ConsentData } from '../services/gdpr.service';
@ApiTags('GDPR')
@Controller('gdpr')
@ -84,13 +77,6 @@ export class GDPRController {
csv += `User Data,${key},"${value}"\n`;
});
// Cookie consent data
if (exportData.cookieConsent) {
Object.entries(exportData.cookieConsent).forEach(([key, value]) => {
csv += `Cookie Consent,${key},"${value}"\n`;
});
}
// Set headers
res.setHeader('Content-Type', 'text/csv');
res.setHeader(
@ -133,26 +119,22 @@ export class GDPRController {
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Record user consent',
description: 'Record consent for cookies (GDPR Article 7)',
description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)',
})
@ApiResponse({
status: 200,
description: 'Consent recorded',
type: ConsentResponseDto,
})
async recordConsent(
@CurrentUser() user: UserPayload,
@Body() body: UpdateConsentDto,
@Req() req: Request
): Promise<ConsentResponseDto> {
// Add IP and user agent from request if not provided
const consentData: UpdateConsentDto = {
@Body() body: Omit<ConsentData, 'userId'>
): Promise<{ success: boolean }> {
await this.gdprService.recordConsent({
...body,
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
userAgent: body.userAgent || req.headers['user-agent'],
};
userId: user.id,
});
return this.gdprService.recordConsent(user.id, consentData);
return { success: true };
}
/**
@ -162,18 +144,19 @@ export class GDPRController {
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Withdraw consent',
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)',
})
@ApiResponse({
status: 200,
description: 'Consent withdrawn',
type: ConsentResponseDto,
})
async withdrawConsent(
@CurrentUser() user: UserPayload,
@Body() body: WithdrawConsentDto
): Promise<ConsentResponseDto> {
return this.gdprService.withdrawConsent(user.id, body.consentType);
@Body() body: { consentType: 'marketing' | 'analytics' }
): Promise<{ success: boolean }> {
await this.gdprService.withdrawConsent(user.id, body.consentType);
return { success: true };
}
/**
@ -187,9 +170,8 @@ export class GDPRController {
@ApiResponse({
status: 200,
description: 'Consent status retrieved',
type: ConsentResponseDto,
})
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
return this.gdprService.getConsentStatus(user.id);
}
}

View File

@ -3,14 +3,12 @@ import {
Post,
Get,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
UseGuards,
Inject,
} from '@nestjs/common';
import {
ApiTags,
@ -19,7 +17,6 @@ import {
ApiBadRequestResponse,
ApiInternalServerErrorResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
import { RateQuoteMapper } from '../mappers';
@ -28,15 +25,8 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import {
AvailableCompaniesDto,
FilterOptionsDto,
AvailableOriginsDto,
AvailableDestinationsDto,
RoutePortInfoDto,
} from '../dto/csv-rate-upload.dto';
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
@ApiTags('Rates')
@Controller('rates')
@ -47,8 +37,7 @@ export class RatesController {
constructor(
private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper,
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
private readonly csvRateMapper: CsvRateMapper
) {}
@Post('search')
@ -282,168 +271,6 @@ export class RatesController {
}
}
/**
* Get available origin ports from CSV rates
* Returns only ports that have routes defined in CSV files
*/
@Get('available-routes/origins')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get available origin ports from CSV rates',
description:
'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available origin ports with details',
type: AvailableOriginsDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
async getAvailableOrigins(): Promise<AvailableOriginsDto> {
this.logger.log('Fetching available origin ports from CSV rates');
try {
// Get unique origin port codes from CSV rates
const originCodes = await this.csvRateSearchService.getAvailableOrigins();
// Fetch port details from database
const ports = await this.portRepository.findByCodes(originCodes);
// Map to response DTO with port details
const origins: RoutePortInfoDto[] = originCodes.map(code => {
const port = ports.find(p => p.code === code);
if (port) {
return {
code: port.code,
name: port.name,
city: port.city,
country: port.country,
countryName: port.countryName,
displayName: port.getDisplayName(),
latitude: port.coordinates.latitude,
longitude: port.coordinates.longitude,
};
}
// Fallback if port not found in database
return {
code,
name: code,
city: '',
country: code.substring(0, 2),
countryName: '',
displayName: code,
};
});
// Sort by display name
origins.sort((a, b) => a.displayName.localeCompare(b.displayName));
return {
origins,
total: origins.length,
};
} catch (error: any) {
this.logger.error(
`Failed to fetch available origins: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/**
* Get available destination ports for a given origin
* Returns only destinations that have routes from the specified origin in CSV files
*/
@Get('available-routes/destinations')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get available destination ports for a given origin',
description:
'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.',
})
@ApiQuery({
name: 'origin',
required: true,
description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)',
example: 'NLRTM',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available destination ports with details',
type: AvailableDestinationsDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Origin port code is required',
})
async getAvailableDestinations(
@Query('origin') origin: string
): Promise<AvailableDestinationsDto> {
this.logger.log(`Fetching available destinations for origin: ${origin}`);
if (!origin) {
throw new Error('Origin port code is required');
}
try {
// Get destination port codes for this origin from CSV rates
const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin);
// Fetch port details from database
const ports = await this.portRepository.findByCodes(destinationCodes);
// Map to response DTO with port details
const destinations: RoutePortInfoDto[] = destinationCodes.map(code => {
const port = ports.find(p => p.code === code);
if (port) {
return {
code: port.code,
name: port.name,
city: port.city,
country: port.country,
countryName: port.countryName,
displayName: port.getDisplayName(),
latitude: port.coordinates.latitude,
longitude: port.coordinates.longitude,
};
}
// Fallback if port not found in database
return {
code,
name: code,
city: '',
country: code.substring(0, 2),
countryName: '',
displayName: code,
};
});
// Sort by display name
destinations.sort((a, b) => a.displayName.localeCompare(b.displayName));
return {
origin: origin.toUpperCase(),
destinations,
total: destinations.length,
};
} catch (error: any) {
this.logger.error(
`Failed to fetch available destinations: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/**
* Get available companies
*/

View File

@ -1,112 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
/**
* DTO for verifying document access password
*/
export class VerifyDocumentAccessDto {
@ApiProperty({ description: 'Password for document access (booking number code)' })
@IsString()
@IsNotEmpty()
password: string;
}
/**
* Response DTO for checking document access requirements
*/
export class DocumentAccessRequirementsDto {
@ApiProperty({ description: 'Whether password is required to access documents' })
requiresPassword: boolean;
@ApiPropertyOptional({ description: 'Booking number (if available)' })
bookingNumber?: string;
@ApiProperty({ description: 'Current booking status' })
status: string;
}
/**
* Booking Summary DTO for Carrier Documents Page
*/
export class BookingSummaryDto {
@ApiProperty({ description: 'Booking unique ID' })
id: string;
@ApiPropertyOptional({ description: 'Human-readable booking number' })
bookingNumber?: string;
@ApiProperty({ description: 'Carrier/Company name' })
carrierName: string;
@ApiProperty({ description: 'Origin port code' })
origin: string;
@ApiProperty({ description: 'Destination port code' })
destination: string;
@ApiProperty({ description: 'Route description (origin -> destination)' })
routeDescription: string;
@ApiProperty({ description: 'Volume in CBM' })
volumeCBM: number;
@ApiProperty({ description: 'Weight in KG' })
weightKG: number;
@ApiProperty({ description: 'Number of pallets' })
palletCount: number;
@ApiProperty({ description: 'Price in the primary currency' })
price: number;
@ApiProperty({ description: 'Currency (USD or EUR)' })
currency: string;
@ApiProperty({ description: 'Transit time in days' })
transitDays: number;
@ApiProperty({ description: 'Container type' })
containerType: string;
@ApiProperty({ description: 'When the booking was accepted' })
acceptedAt: Date;
}
/**
* Document with signed download URL for carrier access
*/
export class DocumentWithUrlDto {
@ApiProperty({ description: 'Document unique ID' })
id: string;
@ApiProperty({
description: 'Document type',
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'],
})
type: string;
@ApiProperty({ description: 'Original file name' })
fileName: string;
@ApiProperty({ description: 'File MIME type' })
mimeType: string;
@ApiProperty({ description: 'File size in bytes' })
size: number;
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
downloadUrl: string;
}
/**
* Carrier Documents Response DTO
*
* Response for carrier document access page
*/
export class CarrierDocumentsResponseDto {
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
booking: BookingSummaryDto;
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
documents: DocumentWithUrlDto[];
}

View File

@ -1,139 +0,0 @@
/**
* Cookie Consent DTOs
* GDPR compliant consent management
*/
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* Request DTO for recording/updating cookie consent
*/
export class UpdateConsentDto {
@ApiProperty({
example: true,
description: 'Essential cookies consent (always true, required for functionality)',
default: true,
})
@IsBoolean()
essential: boolean;
@ApiProperty({
example: false,
description: 'Functional cookies consent (preferences, language, etc.)',
default: false,
})
@IsBoolean()
functional: boolean;
@ApiProperty({
example: false,
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
default: false,
})
@IsBoolean()
analytics: boolean;
@ApiProperty({
example: false,
description: 'Marketing cookies consent (ads, tracking, remarketing)',
default: false,
})
@IsBoolean()
marketing: boolean;
@ApiPropertyOptional({
example: '192.168.1.1',
description: 'IP address at time of consent (for GDPR audit trail)',
})
@IsOptional()
@IsString()
ipAddress?: string;
@ApiPropertyOptional({
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
description: 'User agent at time of consent',
})
@IsOptional()
@IsString()
userAgent?: string;
}
/**
* Response DTO for consent status
*/
export class ConsentResponseDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'User ID',
})
userId: string;
@ApiProperty({
example: true,
description: 'Essential cookies consent (always true)',
})
essential: boolean;
@ApiProperty({
example: false,
description: 'Functional cookies consent',
})
functional: boolean;
@ApiProperty({
example: false,
description: 'Analytics cookies consent',
})
analytics: boolean;
@ApiProperty({
example: false,
description: 'Marketing cookies consent',
})
marketing: boolean;
@ApiProperty({
example: '2025-01-27T10:30:00.000Z',
description: 'Date when consent was recorded',
})
consentDate: Date;
@ApiProperty({
example: '2025-01-27T10:30:00.000Z',
description: 'Last update timestamp',
})
updatedAt: Date;
}
/**
* Request DTO for withdrawing specific consent
*/
export class WithdrawConsentDto {
@ApiProperty({
example: 'marketing',
description: 'Type of consent to withdraw',
enum: ['functional', 'analytics', 'marketing'],
})
@IsEnum(['functional', 'analytics', 'marketing'], {
message: 'Consent type must be functional, analytics, or marketing',
})
consentType: 'functional' | 'analytics' | 'marketing';
}
/**
* Success response DTO
*/
export class ConsentSuccessDto {
@ApiProperty({
example: true,
description: 'Operation success status',
})
success: boolean;
@ApiProperty({
example: 'Consent preferences saved successfully',
description: 'Response message',
})
message: string;
}

View File

@ -201,12 +201,6 @@ export class CsvBookingResponseDto {
})
id: string;
@ApiPropertyOptional({
description: 'Booking number (e.g. XPD-2026-W75VPT)',
example: 'XPD-2026-W75VPT',
})
bookingNumber?: string;
@ApiProperty({
description: 'User ID who created the booking',
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',

View File

@ -209,101 +209,3 @@ export class FilterOptionsDto {
})
currencies: string[];
}
/**
* Port Info for Route Response DTO
* Contains port details with coordinates for map display
*/
export class RoutePortInfoDto {
@ApiProperty({
description: 'UN/LOCODE port code',
example: 'NLRTM',
})
code: string;
@ApiProperty({
description: 'Port name',
example: 'Rotterdam',
})
name: string;
@ApiProperty({
description: 'City name',
example: 'Rotterdam',
})
city: string;
@ApiProperty({
description: 'Country code (ISO 3166-1 alpha-2)',
example: 'NL',
})
country: string;
@ApiProperty({
description: 'Country full name',
example: 'Netherlands',
})
countryName: string;
@ApiProperty({
description: 'Display name for UI',
example: 'Rotterdam, Netherlands (NLRTM)',
})
displayName: string;
@ApiProperty({
description: 'Latitude coordinate',
example: 51.9244,
required: false,
})
latitude?: number;
@ApiProperty({
description: 'Longitude coordinate',
example: 4.4777,
required: false,
})
longitude?: number;
}
/**
* Available Origins Response DTO
* Returns list of origin ports that have routes in CSV rates
*/
export class AvailableOriginsDto {
@ApiProperty({
description: 'List of origin ports with available routes in CSV rates',
type: [RoutePortInfoDto],
})
origins: RoutePortInfoDto[];
@ApiProperty({
description: 'Total number of available origin ports',
example: 15,
})
total: number;
}
/**
* Available Destinations Response DTO
* Returns list of destination ports available for a given origin
*/
export class AvailableDestinationsDto {
@ApiProperty({
description: 'Origin port code that was used to filter destinations',
example: 'NLRTM',
})
origin: string;
@ApiProperty({
description: 'List of destination ports available from the given origin',
type: [RoutePortInfoDto],
})
destinations: RoutePortInfoDto[];
@ApiProperty({
description: 'Total number of available destinations for this origin',
example: 8,
})
total: number;
}

View File

@ -12,7 +12,6 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
@Module({
imports: [
@ -21,7 +20,6 @@ import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm
BookingOrmEntity,
AuditLogOrmEntity,
NotificationOrmEntity,
CookieConsentOrmEntity,
]),
],
controllers: [GDPRController],

View File

@ -1,13 +1,5 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
Inject,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
import { PortCode } from '@domain/value-objects/port-code.vo';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
@ -29,7 +21,6 @@ import {
CsvBookingListResponseDto,
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
/**
* CSV Booking Document (simple class for domain)
@ -65,27 +56,6 @@ export class CsvBookingService {
private readonly storageAdapter: StoragePort
) {}
/**
* Generate a unique booking number
* Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9)
*/
private generateBookingNumber(): string {
const year = new Date().getFullYear();
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return `XPD-${year}-${code}`;
}
/**
* Extract the password from booking number (last 6 characters)
*/
private extractPasswordFromBookingNumber(bookingNumber: string): string {
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
}
/**
* Create a new CSV booking request
*/
@ -102,14 +72,9 @@ export class CsvBookingService {
throw new BadRequestException('At least one document is required');
}
// Generate unique confirmation token and booking number
// Generate unique confirmation token
const confirmationToken = uuidv4();
const bookingId = uuidv4();
const bookingNumber = this.generateBookingNumber();
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber);
// Hash the password for storage
const passwordHash = await argon2.hash(documentPassword);
// Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId);
@ -141,26 +106,13 @@ export class CsvBookingService {
// Save to database
const savedBooking = await this.csvBookingRepository.create(booking);
// Update ORM entity with booking number and password hash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.bookingNumber = bookingNumber;
ormBooking.passwordHash = passwordHash;
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`);
this.logger.log(`CSV booking created with ID: ${bookingId}`);
// Send email to carrier and WAIT for confirmation
// The button waits for the email to be sent before responding
try {
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId,
bookingNumber,
documentPassword,
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
@ -176,7 +128,6 @@ export class CsvBookingService {
fileName: doc.fileName,
})),
confirmationToken,
notes: dto.notes,
});
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) {
@ -250,130 +201,6 @@ export class CsvBookingService {
return this.toResponseDto(booking);
}
/**
* Verify password and get booking documents for carrier (public endpoint)
* Only accessible for ACCEPTED bookings with correct password
*/
async getDocumentsForCarrier(
token: string,
password?: string
): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`);
// Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
// Only allow access for ACCEPTED bookings
if (ormBooking.status !== 'ACCEPTED') {
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
}
// Check if password protection is enabled for this booking
if (ormBooking.passwordHash) {
if (!password) {
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
}
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Mot de passe incorrect');
}
}
// Get domain booking for business logic
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Réservation introuvable');
}
// Generate signed URLs for all documents
const documentsWithUrls = await Promise.all(
booking.documents.map(async doc => {
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
return {
id: doc.id,
type: doc.type,
fileName: doc.fileName,
mimeType: doc.mimeType,
size: doc.size,
downloadUrl: signedUrl,
};
})
);
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
booking: {
id: booking.id,
bookingNumber: ormBooking.bookingNumber || undefined,
carrierName: booking.carrierName,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
routeDescription: booking.getRouteDescription(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
price: booking.getPriceInCurrency(primaryCurrency),
currency: primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
acceptedAt: booking.respondedAt!,
},
documents: documentsWithUrls,
};
}
/**
* Check if a booking requires password for document access
*/
async checkDocumentAccessRequirements(
token: string
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
return {
requiresPassword: !!ormBooking.passwordHash,
bookingNumber: ormBooking.bookingNumber || undefined,
status: ormBooking.status,
};
}
/**
* Generate signed URL for a document file path
*/
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
const bucket = 'xpeditis-documents';
// Extract key from the file path
let key = filePath;
if (filePath.includes('xpeditis-documents/')) {
key = filePath.split('xpeditis-documents/')[1];
} else if (filePath.startsWith('http')) {
const url = new URL(filePath);
key = url.pathname.replace(/^\//, '');
if (key.startsWith('xpeditis-documents/')) {
key = key.replace('xpeditis-documents/', '');
}
}
// Generate signed URL with 1 hour expiration
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
return signedUrl;
}
/**
* Accept a booking request
*/
@ -386,11 +213,6 @@ export class CsvBookingService {
throw new NotFoundException('Booking not found');
}
// Get ORM entity for bookingNumber
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
// Accept the booking (domain logic validates status)
booking.accept();
@ -398,31 +220,6 @@ export class CsvBookingService {
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`);
// Extract password from booking number for the email
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send document access email to carrier
try {
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
bookingNumber: bookingNumber || undefined,
documentPassword: documentPassword,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
documentCount: booking.documents.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
}
// Create notification for user
try {
const notification = Notification.create({
@ -678,9 +475,9 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Allow adding documents to PENDING or ACCEPTED bookings
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
// Verify booking is still pending
if (booking.status !== CsvBookingStatus.PENDING) {
throw new BadRequestException('Cannot add documents to a booking that is not pending');
}
// Upload new documents
@ -709,24 +506,6 @@ export class CsvBookingService {
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
// If booking is ACCEPTED, notify carrier about new documents
if (booking.status === CsvBookingStatus.ACCEPTED) {
try {
await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
newDocumentsCount: newDocuments.length,
totalDocumentsCount: updatedDocuments.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack);
}
}
return {
success: true,
message: 'Documents added successfully',
@ -922,7 +701,6 @@ export class CsvBookingService {
return {
id: booking.id,
bookingNumber: booking.bookingNumber,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,

View File

@ -1,35 +1,37 @@
/**
* GDPR Compliance Service
* GDPR Compliance Service - Simplified Version
*
* Handles data export, deletion, and consent management
* with full database persistence
*/
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
export interface GDPRDataExport {
exportDate: string;
userId: string;
userData: any;
cookieConsent: any;
message: string;
}
export interface ConsentData {
userId: string;
marketing: boolean;
analytics: boolean;
functional: boolean;
consentDate: Date;
ipAddress?: string;
}
@Injectable()
export class GDPRService {
private readonly logger = new Logger(GDPRService.name);
constructor(
@InjectRepository(UserOrmEntity)
private readonly userRepository: Repository<UserOrmEntity>,
@InjectRepository(CookieConsentOrmEntity)
private readonly consentRepository: Repository<CookieConsentOrmEntity>
private readonly userRepository: Repository<UserOrmEntity>
) {}
/**
@ -44,9 +46,6 @@ export class GDPRService {
throw new NotFoundException('User not found');
}
// Fetch consent data
const consent = await this.consentRepository.findOne({ where: { userId } });
// Sanitize user data (remove password hash)
const sanitizedUser = {
id: user.id,
@ -64,15 +63,6 @@ export class GDPRService {
exportDate: new Date().toISOString(),
userId,
userData: sanitizedUser,
cookieConsent: consent
? {
essential: consent.essential,
functional: consent.functional,
analytics: consent.analytics,
marketing: consent.marketing,
consentDate: consent.consentDate,
}
: null,
message:
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
};
@ -98,9 +88,6 @@ export class GDPRService {
}
try {
// Delete consent data first (will cascade with user deletion)
await this.consentRepository.delete({ userId });
// IMPORTANT: In production, implement full data anonymization
// For now, we just mark the account for deletion
// Real implementation should:
@ -118,139 +105,55 @@ export class GDPRService {
}
/**
* Record or update consent (GDPR Article 7 - Conditions for consent)
* Record consent (GDPR Article 7 - Conditions for consent)
*/
async recordConsent(
userId: string,
consentData: UpdateConsentDto
): Promise<ConsentResponseDto> {
this.logger.log(`Recording consent for user ${userId}`);
async recordConsent(consentData: ConsentData): Promise<void> {
this.logger.log(`Recording consent for user ${consentData.userId}`);
const user = await this.userRepository.findOne({
where: { id: consentData.userId },
});
// Verify user exists
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Check if consent already exists
let consent = await this.consentRepository.findOne({ where: { userId } });
if (consent) {
// Update existing consent
consent.essential = true; // Always true
consent.functional = consentData.functional;
consent.analytics = consentData.analytics;
consent.marketing = consentData.marketing;
consent.ipAddress = consentData.ipAddress || consent.ipAddress;
consent.userAgent = consentData.userAgent || consent.userAgent;
consent.consentDate = new Date();
await this.consentRepository.save(consent);
this.logger.log(`Consent updated for user ${userId}`);
} else {
// Create new consent record
consent = this.consentRepository.create({
id: uuidv4(),
userId,
essential: true, // Always true
functional: consentData.functional,
analytics: consentData.analytics,
marketing: consentData.marketing,
ipAddress: consentData.ipAddress,
userAgent: consentData.userAgent,
consentDate: new Date(),
});
await this.consentRepository.save(consent);
this.logger.log(`New consent created for user ${userId}`);
}
return {
userId,
essential: consent.essential,
functional: consent.functional,
analytics: consent.analytics,
marketing: consent.marketing,
consentDate: consent.consentDate,
updatedAt: consent.updatedAt,
};
// In production, store in separate consent table
// For now, just log the consent
this.logger.log(
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
);
}
/**
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
* Withdraw consent (GDPR Article 7.3 - Withdrawal of consent)
*/
async withdrawConsent(
userId: string,
consentType: 'functional' | 'analytics' | 'marketing'
): Promise<ConsentResponseDto> {
async withdrawConsent(userId: string, consentType: 'marketing' | 'analytics'): Promise<void> {
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
// Verify user exists
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Find consent record
let consent = await this.consentRepository.findOne({ where: { userId } });
if (!consent) {
// Create default consent with withdrawn type
consent = this.consentRepository.create({
id: uuidv4(),
userId,
essential: true,
functional: consentType === 'functional' ? false : false,
analytics: consentType === 'analytics' ? false : false,
marketing: consentType === 'marketing' ? false : false,
consentDate: new Date(),
});
} else {
// Update specific consent type
consent[consentType] = false;
consent.consentDate = new Date();
}
await this.consentRepository.save(consent);
this.logger.log(`${consentType} consent withdrawn for user ${userId}`);
return {
userId,
essential: consent.essential,
functional: consent.functional,
analytics: consent.analytics,
marketing: consent.marketing,
consentDate: consent.consentDate,
updatedAt: consent.updatedAt,
};
}
/**
* Get current consent status
*/
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
// Verify user exists
async getConsentStatus(userId: string): Promise<any> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Find consent record
const consent = await this.consentRepository.findOne({ where: { userId } });
if (!consent) {
// No consent recorded yet - return null to indicate user should provide consent
return null;
}
// Default consent status
return {
userId,
essential: consent.essential,
functional: consent.functional,
analytics: consent.analytics,
marketing: consent.marketing,
consentDate: consent.consentDate,
updatedAt: consent.updatedAt,
marketing: false,
analytics: false,
functional: true,
message: 'Consent management fully implemented in production version',
};
}
}

View File

@ -79,8 +79,7 @@ export class CsvBooking {
public readonly requestedAt: Date,
public respondedAt?: Date,
public notes?: string,
public rejectionReason?: string,
public readonly bookingNumber?: string
public rejectionReason?: string
) {
this.validate();
}
@ -362,8 +361,7 @@ export class CsvBooking {
requestedAt: Date,
respondedAt?: Date,
notes?: string,
rejectionReason?: string,
bookingNumber?: string
rejectionReason?: string
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
@ -391,7 +389,6 @@ export class CsvBooking {
booking.respondedAt = respondedAt;
booking.notes = notes;
booking.rejectionReason = rejectionReason;
booking.bookingNumber = bookingNumber;
return booking;
}

View File

@ -85,8 +85,6 @@ export interface EmailPort {
carrierEmail: string,
bookingDetails: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -102,7 +100,6 @@ export interface EmailPort {
fileName: string;
}>;
confirmationToken: string;
notes?: string;
}
): Promise<void>;
@ -123,39 +120,4 @@ export interface EmailPort {
carrierName: string,
temporaryPassword: string
): Promise<void>;
/**
* Send document access email to carrier after booking acceptance
*/
sendDocumentAccessEmail(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
documentCount: number;
confirmationToken: string;
}
): Promise<void>;
/**
* Send notification to carrier when new documents are added
*/
sendNewDocumentsNotification(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
origin: string;
destination: string;
newDocumentsCount: number;
totalDocumentsCount: number;
confirmationToken: string;
}
): Promise<void>;
}

View File

@ -239,60 +239,6 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return Array.from(types).sort();
}
/**
* Get all unique origin port codes from CSV rates
* Used to limit port selection to only those with available routes
*/
async getAvailableOrigins(): Promise<string[]> {
const allRates = await this.loadAllRates();
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
return Array.from(origins).sort();
}
/**
* Get all destination port codes available for a given origin
* Used to limit destination selection based on selected origin
*/
async getAvailableDestinations(origin: string): Promise<string[]> {
const allRates = await this.loadAllRates();
const originCode = PortCode.create(origin);
const destinations = new Set(
allRates
.filter(rate => rate.origin.equals(originCode))
.map(rate => rate.destination.getValue())
);
return Array.from(destinations).sort();
}
/**
* Get all available routes (origin-destination pairs) from CSV rates
* Returns a map of origin codes to their available destination codes
*/
async getAvailableRoutes(): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates();
const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => {
const origin = rate.origin.getValue();
const destination = rate.destination.getValue();
if (!routeMap.has(origin)) {
routeMap.set(origin, new Set());
}
routeMap.get(origin)!.add(destination);
});
// Convert Sets to sorted arrays
const result = new Map<string, string[]>();
routeMap.forEach((destinations, origin) => {
result.set(origin, Array.from(destinations).sort());
});
return result;
}
/**
* Load all rates from all CSV files
*/

View File

@ -239,8 +239,6 @@ export class EmailAdapter implements EmailPort {
carrierEmail: string,
bookingData: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -256,7 +254,6 @@ export class EmailAdapter implements EmailPort {
fileName: string;
}>;
confirmationToken: string;
notes?: string;
}
): Promise<void> {
// Use APP_URL (frontend) for accept/reject links
@ -273,7 +270,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: carrierEmail,
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin}${bookingData.destination}`,
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`,
html,
});
@ -430,194 +427,4 @@ export class EmailAdapter implements EmailPort {
this.logger.log(`Carrier password reset email sent to ${email}`);
}
/**
* Send document access email to carrier after booking acceptance
*/
async sendDocumentAccessEmail(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
documentCount: number;
confirmationToken: string;
}
): Promise<void> {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
// Password section HTML - only show if password is set
const passwordSection = data.documentPassword
? `
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #92400e; font-size: 16px;">🔐 Mot de passe d'accès aux documents</h3>
<p style="margin: 0; color: #78350f;">Pour accéder aux documents, vous aurez besoin du mot de passe suivant :</p>
<div style="background: white; border-radius: 6px; padding: 15px; margin-top: 15px; text-align: center;">
<code style="font-size: 24px; font-weight: bold; color: #1e293b; letter-spacing: 2px;">${data.documentPassword}</code>
</div>
<p style="margin: 15px 0 0 0; color: #78350f; font-size: 13px;"> Conservez ce mot de passe, il vous sera demandé à chaque accès.</p>
</div>
`
: '';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #0284c7; margin: 0 10px; }
.summary { background: #f8fafc; border-radius: 8px; padding: 20px; margin: 20px 0; }
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
.summary-row:last-child { border-bottom: none; }
.documents-badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; margin: 20px 0; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Documents disponibles</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
${data.bookingNumber ? `<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">N° ${data.bookingNumber}</p>` : ''}
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.</p>
<div class="route">
${data.origin} <span class="route-arrow"></span> ${data.destination}
</div>
<div class="summary">
<div class="summary-row">
<span style="color: #64748b;">Volume</span>
<span style="font-weight: 500;">${data.volumeCBM} CBM</span>
</div>
<div class="summary-row">
<span style="color: #64748b;">Poids</span>
<span style="font-weight: 500;">${data.weightKG} kg</span>
</div>
</div>
<div style="text-align: center;">
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
</div>
${passwordSection}
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
</div>
<div class="footer">
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
await this.send({
to: carrierEmail,
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin}${data.destination}`,
html,
});
this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`);
}
/**
* Send notification to carrier when new documents are added
*/
async sendNewDocumentsNotification(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
origin: string;
destination: string;
newDocumentsCount: number;
totalDocumentsCount: number;
confirmationToken: string;
}
): Promise<void> {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #f59e0b; margin: 0 10px; }
.highlight { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; margin: 20px 0; text-align: center; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Nouveaux documents ajoutes</h1>
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>De nouveaux documents ont ete ajoutes a votre reservation.</p>
<div class="route">
${data.origin} <span class="route-arrow"></span> ${data.destination}
</div>
<div class="highlight">
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #92400e;">
+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}
</p>
<p style="margin: 5px 0 0 0; color: #a16207;">
Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}
</p>
</div>
<a href="${documentsUrl}" class="cta-button">Voir les documents</a>
</div>
<div class="footer">
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
await this.send({
to: carrierEmail,
subject: `Nouveaux documents - Reservation ${data.origin}${data.destination}`,
html,
});
this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`);
}
}

View File

@ -261,8 +261,6 @@ export class EmailTemplates {
*/
async renderCsvBookingRequest(data: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -277,7 +275,6 @@ export class EmailTemplates {
type: string;
fileName: string;
}>;
notes?: string;
acceptUrl: string;
rejectUrl: string;
}): Promise<string> {
@ -484,21 +481,6 @@ export class EmailTemplates {
Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande.
</p>
{{#if bookingNumber}}
<!-- Booking Reference Box -->
<div style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border: 2px solid #0284c7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #0369a1;">Numéro de devis</p>
<p style="margin: 0; font-size: 28px; font-weight: bold; color: #0c4a6e; letter-spacing: 2px;">{{bookingNumber}}</p>
{{#if documentPassword}}
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #bae6fd;">
<p style="margin: 0 0 5px 0; font-size: 12px; color: #0369a1;">🔐 Mot de passe pour accéder aux documents</p>
<p style="margin: 0; font-size: 20px; font-weight: bold; color: #0c4a6e; background: white; display: inline-block; padding: 8px 16px; border-radius: 4px; letter-spacing: 3px;">{{documentPassword}}</p>
<p style="margin: 10px 0 0 0; font-size: 11px; color: #64748b;">Conservez ce mot de passe, il vous sera demandé pour télécharger les documents</p>
</div>
{{/if}}
</div>
{{/if}}
<!-- Booking Details -->
<div class="section-title">📋 Détails du transport</div>
<table class="details-table">
@ -558,14 +540,6 @@ export class EmailTemplates {
</ul>
</div>
{{#if notes}}
<!-- Notes -->
<div style="background-color: #f0f9ff; border-left: 4px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 5px 0; font-weight: 700; color: #045a8d; font-size: 14px;">📝 Notes du client</p>
<p style="margin: 0; font-size: 14px; color: #333; line-height: 1.6;">{{notes}}</p>
</div>
{{/if}}
<!-- Action Buttons -->
<div class="action-buttons">
<p>Veuillez confirmer votre décision :</p>

View File

@ -1,58 +0,0 @@
/**
* Cookie Consent ORM Entity (Infrastructure Layer)
*
* TypeORM entity for cookie consent persistence
*/
import {
Entity,
Column,
PrimaryColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserOrmEntity } from './user.orm-entity';
@Entity('cookie_consents')
@Index('idx_cookie_consents_user', ['userId'])
export class CookieConsentOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid', unique: true })
userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
@Column({ type: 'boolean', default: true })
essential: boolean;
@Column({ type: 'boolean', default: false })
functional: boolean;
@Column({ type: 'boolean', default: false })
analytics: boolean;
@Column({ type: 'boolean', default: false })
marketing: boolean;
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
ipAddress: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string | null;
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
consentDate: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -96,13 +96,6 @@ export class CsvBookingOrmEntity {
@Index()
confirmationToken: string;
@Column({ name: 'booking_number', type: 'varchar', length: 20, nullable: true })
@Index()
bookingNumber: string | null;
@Column({ name: 'password_hash', type: 'text', nullable: true })
passwordHash: string | null;
@Column({ name: 'requested_at', type: 'timestamp with time zone' })
@Index()
requestedAt: Date;

View File

@ -41,8 +41,7 @@ export class CsvBookingMapper {
ormEntity.requestedAt,
ormEntity.respondedAt,
ormEntity.notes,
ormEntity.rejectionReason,
ormEntity.bookingNumber ?? undefined
ormEntity.rejectionReason
);
}

View File

@ -1,62 +0,0 @@
/**
* Migration: Create Cookie Consents Table
* GDPR compliant cookie preference storage
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCookieConsent1738100000000 implements MigrationInterface {
name = 'CreateCookieConsent1738100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create cookie_consents table
await queryRunner.query(`
CREATE TABLE "cookie_consents" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL,
"essential" BOOLEAN NOT NULL DEFAULT TRUE,
"functional" BOOLEAN NOT NULL DEFAULT FALSE,
"analytics" BOOLEAN NOT NULL DEFAULT FALSE,
"marketing" BOOLEAN NOT NULL DEFAULT FALSE,
"ip_address" VARCHAR(45) NULL,
"user_agent" TEXT NULL,
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id") ON DELETE CASCADE
)
`);
// Create index for fast user lookups
await queryRunner.query(`
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
`);
// Add comments
await queryRunner.query(`
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
`);
await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
`);
await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
`);
await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
`);
await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
`);
await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "cookie_consents"`);
}
}

View File

@ -1,48 +0,0 @@
/**
* Migration: Add Password Protection to CSV Bookings
*
* Adds password protection for carrier document access
* Including: booking_number (readable ID) and password_hash
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPasswordToCsvBookings1738200000000 implements MigrationInterface {
name = 'AddPasswordToCsvBookings1738200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add password-related columns to csv_bookings
await queryRunner.query(`
ALTER TABLE "csv_bookings"
ADD COLUMN "booking_number" VARCHAR(20) NULL,
ADD COLUMN "password_hash" TEXT NULL
`);
// Create unique index for booking_number
await queryRunner.query(`
CREATE UNIQUE INDEX "idx_csv_bookings_booking_number"
ON "csv_bookings" ("booking_number")
WHERE "booking_number" IS NOT NULL
`);
// Add comments
await queryRunner.query(`
COMMENT ON COLUMN "csv_bookings"."booking_number" IS 'Human-readable booking number (format: XPD-YYYY-XXXXXX)'
`);
await queryRunner.query(`
COMMENT ON COLUMN "csv_bookings"."password_hash" IS 'Argon2 hashed password for carrier document access'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Remove index first
await queryRunner.query(`DROP INDEX IF EXISTS "idx_csv_bookings_booking_number"`);
// Remove columns
await queryRunner.query(`
ALTER TABLE "csv_bookings"
DROP COLUMN IF EXISTS "booking_number",
DROP COLUMN IF EXISTS "password_hash"
`);
}
}

View File

@ -1,568 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import Image from 'next/image';
import {
FileText,
Download,
Loader2,
XCircle,
Package,
Ship,
Clock,
AlertCircle,
ArrowRight,
Lock,
Eye,
EyeOff,
} from 'lucide-react';
interface Document {
id: string;
type: string;
fileName: string;
mimeType: string;
size: number;
downloadUrl: string;
}
interface BookingSummary {
id: string;
bookingNumber?: string;
carrierName: string;
origin: string;
destination: string;
routeDescription: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
price: number;
currency: string;
transitDays: number;
containerType: string;
acceptedAt: string;
}
interface CarrierDocumentsData {
booking: BookingSummary;
documents: Document[];
}
interface AccessRequirements {
requiresPassword: boolean;
bookingNumber?: string;
status: string;
}
const documentTypeLabels: Record<string, string> = {
BILL_OF_LADING: 'Connaissement',
PACKING_LIST: 'Liste de colisage',
COMMERCIAL_INVOICE: 'Facture commerciale',
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
OTHER: 'Autre document',
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('image')) return '🖼️';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
if (mimeType.includes('word') || mimeType.includes('document')) return '📝';
return '📎';
};
export default function CarrierDocumentsPage() {
const params = useParams();
const token = params.token as string;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<CarrierDocumentsData | null>(null);
const [downloading, setDownloading] = useState<string | null>(null);
// Password protection state
const [requirements, setRequirements] = useState<AccessRequirements | null>(null);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [verifying, setVerifying] = useState(false);
const hasCalledApi = useRef(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
// Check access requirements first
const checkRequirements = async () => {
if (!token) {
setError('Lien invalide');
setLoading(false);
return;
}
try {
const response = await fetch(
`${apiUrl}/api/v1/csv-booking-actions/documents/${token}/requirements`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors du chargement';
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
}
throw new Error(errorMessage);
}
const reqData: AccessRequirements = await response.json();
setRequirements(reqData);
// If booking is not accepted yet
if (reqData.status !== 'ACCEPTED') {
setError(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
setLoading(false);
return;
}
// If no password required, fetch documents directly
if (!reqData.requiresPassword) {
await fetchDocumentsWithoutPassword();
} else {
setLoading(false);
}
} catch (err) {
console.error('Error checking requirements:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setLoading(false);
}
};
// Fetch documents without password (legacy bookings)
const fetchDocumentsWithoutPassword = async () => {
try {
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
if (
errorMessage.includes('pas encore été acceptée') ||
errorMessage.includes('not accepted')
) {
throw new Error(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
// Password is now required, show the form
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
setLoading(false);
return;
}
throw new Error(errorMessage);
}
const responseData = await response.json();
setData(responseData);
setLoading(false);
} catch (err) {
console.error('Error fetching documents:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setLoading(false);
}
};
// Fetch documents with password
const fetchDocumentsWithPassword = async (pwd: string) => {
setVerifying(true);
setPasswordError(null);
try {
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: pwd }),
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors de la vérification';
if (
response.status === 401 ||
errorMessage.includes('incorrect') ||
errorMessage.includes('invalid')
) {
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
setVerifying(false);
return;
}
throw new Error(errorMessage);
}
const responseData = await response.json();
setData(responseData);
setVerifying(false);
} catch (err) {
console.error('Error verifying password:', err);
setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
setVerifying(false);
}
};
useEffect(() => {
if (hasCalledApi.current) return;
hasCalledApi.current = true;
checkRequirements();
}, [token]);
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setPasswordError('Veuillez entrer le mot de passe');
return;
}
fetchDocumentsWithPassword(password.trim());
};
const handleDownload = async (doc: Document) => {
setDownloading(doc.id);
try {
// The downloadUrl is already a signed URL, open it directly
window.open(doc.downloadUrl, '_blank');
} catch (err) {
console.error('Error downloading document:', err);
alert('Erreur lors du téléchargement. Veuillez réessayer.');
} finally {
// Small delay to show loading state
setTimeout(() => setDownloading(null), 500);
}
};
const handleRefresh = () => {
setLoading(true);
setError(null);
setData(null);
setRequirements(null);
setPassword('');
setPasswordError(null);
hasCalledApi.current = false;
checkRequirements();
};
// Loading state
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
<p className="text-gray-600">Veuillez patienter</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={handleRefresh}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
>
Réessayer
</button>
</div>
</div>
);
}
// Password form state
if (requirements?.requiresPassword && !data) {
return (
<div className="min-h-screen flex items-center justify-center bg-brand-gray p-4">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-turquoise" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
<p className="text-gray-600">
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
documents.
</p>
{requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500">
Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p>
)}
</div>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Mot de passe
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
value={password}
onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder="Ex: A3B7K9"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{passwordError && (
<p className="mt-2 text-sm text-red-600 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordError}
</p>
)}
</div>
<button
type="submit"
disabled={verifying}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2"
>
{verifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Vérification...
</>
) : (
<>
<Lock className="w-5 h-5" />
Accéder aux documents
</>
)}
</button>
</form>
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800">
<strong> trouver le mot de passe ?</strong>
<br />
Le mot de passe vous a é envoyé dans l'email de confirmation de la réservation. Il
correspond aux 6 derniers caractères du numéro de devis.
</p>
</div>
</div>
</div>
);
}
if (!data) return null;
const { booking, documents } = data;
return (
<div className="min-h-screen bg-brand-gray">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={40}
height={48}
className="h-auto"
/>
</div>
<button
onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
>
Actualiser
</button>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Booking Summary Card */}
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
<div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
<div className="flex items-center justify-center gap-4 text-white">
<span className="text-2xl font-bold">{booking.origin}</span>
<ArrowRight className="w-6 h-6" />
<span className="text-2xl font-bold">{booking.destination}</span>
</div>
{booking.bookingNumber && (
<p className="text-center text-white/70 text-sm mt-1">
N° {booking.bookingNumber}
</p>
)}
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Volume</p>
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Poids</p>
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Transit</p>
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Type</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500">
Transporteur:{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span>
<span className="text-gray-500">
Ref:{' '}
<span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
</span>
</div>
</div>
</div>
{/* Documents Section */}
<div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-brand-turquoise" />
Documents ({documents.length})
</h2>
</div>
{documents.length === 0 ? (
<div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
<p className="text-gray-500 text-sm mt-1">
Les documents apparaîtront ici une fois ajoutés.
</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{documents.map(doc => (
<div
key={doc.id}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-2xl">{getFileIcon(doc.mimeType)}</span>
<div>
<p className="font-medium text-gray-900">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
{documentTypeLabels[doc.type] || doc.type}
</span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
</div>
</div>
</div>
<button
onClick={() => handleDownload(doc)}
disabled={downloading === doc.id}
className="flex items-center gap-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{downloading === doc.id ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span>Télécharger</span>
</>
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Info */}
<p className="mt-6 text-center text-sm text-gray-500">
Cette page affiche toujours les documents les plus récents de la réservation.
</p>
</main>
{/* Footer */}
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</footer>
</div>
);
}

View File

@ -2,8 +2,6 @@
import { useState, useEffect, useCallback } from 'react';
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
interface Document {
id: string;
@ -228,31 +226,30 @@ export default function AdminDocumentsPage() {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
const getDocumentIcon = (type: string): ReactNode => {
const getDocumentIcon = (type: string) => {
const typeLower = type.toLowerCase();
const cls = "h-6 w-6";
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
pdf: <FileText className={`${cls} text-red-500`} />,
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
jpg: <ImageIcon className={`${cls} text-green-500`} />,
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-blue-500`} />,
doc: <FileEdit className={`${cls} text-blue-500`} />,
docx: <FileEdit className={`${cls} text-blue-500`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
text: <FileText className={`${cls} text-gray-500`} />,
txt: <FileText className={`${cls} text-gray-500`} />,
const icons: Record<string, string> = {
'application/pdf': '📄',
'image/jpeg': '🖼️',
'image/png': '🖼️',
'image/jpg': '🖼️',
pdf: '📄',
jpeg: '🖼️',
jpg: '🖼️',
png: '🖼️',
gif: '🖼️',
image: '🖼️',
word: '📝',
doc: '📝',
docx: '📝',
excel: '📊',
xls: '📊',
xlsx: '📊',
csv: '📊',
text: '📄',
txt: '📄',
};
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
return icons[typeLower] || '📎';
};
const getStatusColor = (status: string) => {
@ -448,7 +445,7 @@ export default function AdminDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<span className="text-2xl mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>

View File

@ -8,7 +8,6 @@
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ClipboardList, Mail, AlertTriangle } from 'lucide-react';
import type { CsvRateSearchResult } from '@/types/rates';
import { createCsvBooking } from '@/lib/api/bookings';
@ -50,7 +49,6 @@ function NewBookingPageContent() {
const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<BookingForm>({
@ -189,7 +187,7 @@ function NewBookingPageContent() {
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
const canProceedToStep3 = formData.documents.length >= 1;
const canSubmit = canProceedToStep3 && termsAccepted;
const canSubmit = canProceedToStep3;
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('fr-FR', {
@ -219,10 +217,10 @@ function NewBookingPageContent() {
</div>
{/* Progress Steps */}
<div className="mb-8 bg-white rounded-lg shadow-md p-6">
<div className="mb-8">
<div className="flex items-center justify-between">
{[1, 2, 3].map(step => (
<div key={step} className={`flex items-center ${step < 3 ? 'flex-1' : ''}`}>
<div key={step} className="flex items-center flex-1">
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
@ -261,7 +259,7 @@ function NewBookingPageContent() {
{error && (
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
<div className="flex items-start">
<AlertTriangle className="h-6 w-6 mr-3 text-red-500 flex-shrink-0" />
<span className="text-2xl mr-3"></span>
<div>
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
<p className="text-red-700 whitespace-pre-line">{error}</p>
@ -386,7 +384,7 @@ function NewBookingPageContent() {
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<ClipboardList className="h-6 w-6 mr-3 text-yellow-700 flex-shrink-0" />
<span className="text-2xl mr-3">📋</span>
<div>
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
<p className="text-sm text-yellow-800">
@ -574,7 +572,7 @@ function NewBookingPageContent() {
{/* What happens next */}
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
<Mail className="h-5 w-5 mr-2 inline-block" /> Que se passe-t-il ensuite ?
📧 Que se passe-t-il ensuite ?
</h3>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start">
@ -609,9 +607,8 @@ function NewBookingPageContent() {
<label className="flex items-start cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
required
/>
<span className="ml-3 text-sm text-gray-700">
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}

View File

@ -10,8 +10,6 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
@ -148,38 +146,14 @@ export default function BookingsListPage() {
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredBookings}
filename="reservations"
columns={[
{ key: 'id', label: 'ID' },
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
{ key: 'origin', label: 'Origine' },
{ key: 'destination', label: 'Destination' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[v] || v;
}},
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<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" />
<span className="mr-2"></span>
Nouvelle Réservation
</Link>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4">
@ -288,11 +262,8 @@ export default function BookingsListPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
N° Devis
</th>
</tr>
</thead>
@ -355,14 +326,11 @@ export default function BookingsListPage() {
})
: 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
{booking.bookingNumber || '-'}
</td>
</tr>
))}
</tbody>
@ -482,7 +450,7 @@ export default function BookingsListPage() {
href="/dashboard/search-advanced"
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" />
<span className="mr-2"></span>
Nouvelle Réservation
</Link>
</div>

View File

@ -2,9 +2,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import ExportButton from '@/components/ExportButton';
interface Document {
id: string;
@ -167,31 +164,30 @@ export default function UserDocumentsPage() {
setCurrentPage(1);
}, [searchTerm, filterStatus, filterQuoteNumber]);
const getDocumentIcon = (type: string): ReactNode => {
const getDocumentIcon = (type: string) => {
const typeLower = type.toLowerCase();
const cls = "h-6 w-6";
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
pdf: <FileText className={`${cls} text-red-500`} />,
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
jpg: <ImageIcon className={`${cls} text-green-500`} />,
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-blue-500`} />,
doc: <FileEdit className={`${cls} text-blue-500`} />,
docx: <FileEdit className={`${cls} text-blue-500`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
text: <FileText className={`${cls} text-gray-500`} />,
txt: <FileText className={`${cls} text-gray-500`} />,
const icons: Record<string, string> = {
'application/pdf': '📄',
'image/jpeg': '🖼️',
'image/png': '🖼️',
'image/jpg': '🖼️',
pdf: '📄',
jpeg: '🖼️',
jpg: '🖼️',
png: '🖼️',
gif: '🖼️',
image: '🖼️',
word: '📝',
doc: '📝',
docx: '📝',
excel: '📊',
xls: '📊',
xlsx: '📊',
csv: '📊',
text: '📄',
txt: '📄',
};
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
return icons[typeLower] || '📎';
};
const getStatusColor = (status: string) => {
@ -259,10 +255,8 @@ export default function UserDocumentsPage() {
}
};
// Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
);
// Get unique bookings for add document modal
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
const handleAddDocumentClick = () => {
setShowAddModal(true);
@ -413,31 +407,9 @@ export default function UserDocumentsPage() {
Gérez tous les documents de vos réservations
</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredDocuments}
filename="documents"
columns={[
{ key: 'fileName', label: 'Nom du fichier' },
{ key: 'fileType', label: 'Type' },
{ key: 'quoteNumber', label: 'N° de Devis' },
{ key: 'route', label: 'Route' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[v] || v;
}},
{ key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<button
onClick={handleAddDocumentClick}
disabled={bookingsAvailableForDocuments.length === 0}
disabled={bookingsWithPendingStatus.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">
@ -451,7 +423,6 @@ export default function UserDocumentsPage() {
Ajouter un document
</button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@ -562,7 +533,7 @@ export default function UserDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">
<span className="text-2xl mr-2">
{getDocumentIcon(doc.fileType || doc.type)}
</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
@ -815,7 +786,7 @@ export default function UserDocumentsPage() {
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sélectionner une réservation
Sélectionner une réservation (en attente)
</label>
<select
value={selectedBookingId || ''}
@ -823,9 +794,9 @@ export default function UserDocumentsPage() {
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">-- Choisir une réservation --</option>
{bookingsAvailableForDocuments.map(booking => (
{bookingsWithPendingStatus.map(booking => (
<option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
{getQuoteNumber(booking)} - {booking.origin} {booking.destination}
</option>
))}
</select>

View File

@ -13,32 +13,25 @@ import { useState } from 'react';
import NotificationDropdown from '@/components/NotificationDropdown';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image';
import {
BarChart3,
Package,
FileText,
Search,
BookOpen,
Building2,
Users,
LogOut,
} from 'lucide-react';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
// { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
const navigation = [
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
{ name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' },
{ name: 'Wiki', href: '/dashboard/wiki', icon: '📚' },
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
] : []),
];
@ -105,7 +98,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<item.icon className="mr-3 h-5 w-5" />
<span className="mr-3 text-xl">{item.icon}</span>
{item.name}
</Link>
))}
@ -136,8 +129,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
onClick={logout}
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
<LogOut className="w-4 h-4 mr-2" />
Déconnexion
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Logout
</button>
</div>
</div>
@ -162,17 +162,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button>
<div className="flex-1 lg:flex-none">
<h1 className="text-xl font-semibold text-gray-900">
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
{navigation.find(item => isActive(item.href))?.name || 'Dashboard'}
</h1>
</div>
<div className="flex items-center space-x-4">
{/* Notifications */}
<NotificationDropdown />
{/* User Initials */}
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Link>
{/* User Role Badge */}
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{user?.role}
</span>
</div>
</div>

View File

@ -16,8 +16,7 @@ import {
deleteNotification,
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, RefreshCw, XCircle, CheckCircle, Mail, Clock, FileText, Megaphone, User, Building2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight } from 'lucide-react';
export default function NotificationsPage() {
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
@ -78,7 +77,7 @@ export default function NotificationsPage() {
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
if (confirm('Are you sure you want to delete this notification?')) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -93,23 +92,22 @@ export default function NotificationsPage() {
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
};
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = "h-8 w-8";
const icons: Record<string, ReactNode> = {
booking_created: <Package className={`${iconClass} text-blue-600`} />,
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-blue-500`} />,
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-gray-600`} />,
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
user_invited: <User className={`${iconClass} text-teal-500`} />,
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
booking_created: '📦',
booking_updated: '🔄',
booking_cancelled: '❌',
booking_confirmed: '✅',
csv_booking_accepted: '✅',
csv_booking_rejected: '❌',
csv_booking_request_sent: '📧',
rate_quote_expiring: '⏰',
document_uploaded: '📄',
system_announcement: '📢',
user_invited: '👤',
organization_update: '🏢',
};
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-gray-500`} />;
return icons[type.toLowerCase()] || '🔔';
};
const formatTime = (dateString: string) => {
@ -120,11 +118,11 @@ export default function NotificationsPage() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'A l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -146,8 +144,8 @@ export default function NotificationsPage() {
<div>
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
<p className="text-sm text-gray-600 mt-1">
{total} notification{total !== 1 ? 's' : ''} au total
{unreadCount > 0 && `${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
{total} notification{total !== 1 ? 's' : ''} total
{unreadCount > 0 && `${unreadCount} unread`}
</p>
</div>
</div>
@ -158,7 +156,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<CheckCheck className="w-5 h-5" />
<span>Tout marquer comme lu</span>
<span>Mark all as read</span>
</button>
)}
</div>
@ -171,7 +169,7 @@ export default function NotificationsPage() {
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex items-center space-x-3">
<Filter className="w-5 h-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Filtrer :</span>
<span className="text-sm font-medium text-gray-700">Filter:</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map((filter) => (
<button
@ -186,7 +184,7 @@ export default function NotificationsPage() {
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{filter.charAt(0).toUpperCase() + filter.slice(1)}
{filter === 'unread' && unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
{unreadCount}
@ -204,18 +202,18 @@ export default function NotificationsPage() {
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">Chargement des notifications...</p>
<p className="text-gray-500">Loading notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
<div className="text-7xl mb-4">🔔</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">No notifications</h3>
<p className="text-gray-500">
{selectedFilter === 'unread'
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
? "You're all caught up! Great job!"
: 'No notifications to display'}
</p>
</div>
</div>
@ -231,7 +229,7 @@ export default function NotificationsPage() {
>
<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 text-4xl">
{getNotificationIcon(notification.type)}
</div>
@ -245,14 +243,14 @@ export default function NotificationsPage() {
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>NOUVEAU</span>
<span>NEW</span>
</span>
)}
</div>
<button
onClick={(e) => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
title="Supprimer la notification"
title="Delete notification"
>
<Trash2 className="w-5 h-5 text-red-600" />
</button>
@ -302,7 +300,7 @@ export default function NotificationsPage() {
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
<span>Voir les tails</span>
<span>View details</span>
<svg
className="w-4 h-4"
fill="none"
@ -332,10 +330,11 @@ export default function NotificationsPage() {
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Page <span className="font-semibold">{currentPage}</span> sur{' '}
Showing page <span className="font-semibold">{currentPage}</span> of{' '}
<span className="font-semibold">{totalPages}</span>
{' • '}
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
<span className="font-semibold">{total}</span> total notification
{total !== 1 ? 's' : ''}
</div>
<div className="flex items-center space-x-2">
<button
@ -344,7 +343,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>Précédent</span>
<span>Previous</span>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -379,7 +378,7 @@ export default function NotificationsPage() {
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>Suivant</span>
<span>Next</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>

View File

@ -21,7 +21,6 @@ import {
Plus,
ArrowRight,
} from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import {
PieChart,
Pie,
@ -75,33 +74,17 @@ export default function DashboardPage() {
<div>
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
<p className="text-gray-600 mt-1 text-sm">
Vue d'ensemble de vos réservations et performances
Vue d'ensemble de vos bookings et performances
</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={topCarriers || []}
filename="tableau-de-bord-transporteurs"
columns={[
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'totalBookings', label: 'Total Réservations' },
{ key: 'acceptedBookings', label: 'Acceptées' },
{ key: 'rejectedBookings', label: 'Refusées' },
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
]}
/>
<Link href="/dashboard/search-advanced">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg text-base px-6 py-5 font-semibold">
<Plus className="h-5 w-5" />
Nouvelle Réservation
<Link href="/dashboard/bookings">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-sm">
<Plus className="h-4 w-4" />
Nouveau Booking
</Button>
</Link>
</div>
</div>
{/* KPI Cards - Compact with Color */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@ -208,7 +191,7 @@ export default function DashboardPage() {
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
Distribution des Réservations
Distribution des Bookings
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Répartition par statut
@ -250,7 +233,7 @@ export default function DashboardPage() {
Poids par Transporteur
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Top 5 transporteurs par poids (KG)
Top 5 carriers par poids (KG)
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -299,7 +282,7 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Package className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
<p className="text-xs font-medium text-gray-600 mb-1">Total Bookings</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
@ -377,7 +360,7 @@ export default function DashboardPage() {
{carrier.carrierName}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{carrier.totalBookings} réservations</span>
<span>{carrier.totalBookings} bookings</span>
<span></span>
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
</div>
@ -417,15 +400,15 @@ export default function DashboardPage() {
<Package className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">
Aucune réservation
Aucun booking
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
Créez votre première réservation pour voir vos statistiques
Créez votre premier booking pour voir vos statistiques
</p>
<Link href="/dashboard/bookings">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-1.5 h-3 w-3" />
Créer une réservation
Créer un booking
</Button>
</Link>
</div>

View File

@ -17,18 +17,18 @@ 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'),
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z
.string()
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
.min(12, 'Password must be at least 12 characters')
.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'
'Password must contain uppercase, lowercase, number, and special character'
),
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: 'Les mots de passe ne correspondent pas',
message: "Passwords don't match",
path: ['confirmPassword'],
});
@ -36,9 +36,9 @@ 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'),
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
});
type ProfileFormData = z.infer<typeof profileSchema>;
@ -101,14 +101,14 @@ export default function ProfilePage() {
return updateUser(user.id, data);
},
onSuccess: () => {
setSuccessMessage('Profil mis à jour avec succès !');
setSuccessMessage('Profile updated successfully!');
setErrorMessage('');
refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setErrorMessage(error.message || 'Failed to update profile');
setSuccessMessage('');
},
});
@ -122,7 +122,7 @@ export default function ProfilePage() {
});
},
onSuccess: () => {
setSuccessMessage('Mot de passe mis à jour avec succès !');
setSuccessMessage('Password updated successfully!');
setErrorMessage('');
passwordForm.reset({
currentPassword: '',
@ -132,7 +132,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
setErrorMessage(error.message || 'Failed to update password');
setSuccessMessage('');
},
});
@ -151,7 +151,7 @@ export default function ProfilePage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement du profil...</p>
<p className="text-gray-600">Loading profile...</p>
</div>
</div>
);
@ -162,12 +162,12 @@ export default function ProfilePage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
<p className="text-red-600 mb-4">Unable to load user profile</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Réessayer
Retry
</button>
</div>
</div>
@ -178,8 +178,8 @@ export default function ProfilePage() {
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
<h1 className="text-3xl font-bold mb-2">My Profile</h1>
<p className="text-blue-100">Manage your account settings and preferences</p>
</div>
{/* Success/Error Messages */}
@ -230,7 +230,7 @@ export default function ProfilePage() {
{user?.role}
</span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Actif
Active
</span>
</div>
</div>
@ -249,7 +249,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Informations personnelles
Profile Information
</button>
<button
onClick={() => setActiveTab('password')}
@ -259,7 +259,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Modifier le mot de passe
Change Password
</button>
</nav>
</div>
@ -274,7 +274,7 @@ export default function ProfilePage() {
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prénom
First Name
</label>
<input
{...profileForm.register('firstName')}
@ -295,7 +295,7 @@ export default function ProfilePage() {
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nom
Last Name
</label>
<input
{...profileForm.register('lastName')}
@ -314,7 +314,7 @@ export default function ProfilePage() {
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Adresse email
Email Address
</label>
<input
{...profileForm.register('email')}
@ -323,7 +323,7 @@ export default function ProfilePage() {
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500">L&apos;adresse email ne peut pas être modifiée</p>
<p className="mt-1 text-xs text-gray-500">Email cannot be changed</p>
</div>
{/* Submit Button */}
@ -333,7 +333,7 @@ export default function ProfilePage() {
disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
@ -345,7 +345,7 @@ export default function ProfilePage() {
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Mot de passe actuel
Current Password
</label>
<input
{...passwordForm.register('currentPassword')}
@ -367,7 +367,7 @@ export default function ProfilePage() {
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nouveau mot de passe
New Password
</label>
<input
{...passwordForm.register('newPassword')}
@ -382,7 +382,8 @@ export default function ProfilePage() {
</p>
)}
<p className="mt-1 text-xs text-gray-500">
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
Must be at least 12 characters with uppercase, lowercase, number, and special
character
</p>
</div>
@ -392,7 +393,7 @@ export default function ProfilePage() {
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirmer le nouveau mot de passe
Confirm New Password
</label>
<input
{...passwordForm.register('confirmPassword')}
@ -415,7 +416,7 @@ export default function ProfilePage() {
disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
{updatePasswordMutation.isPending ? 'Updating...' : 'Update Password'}
</button>
</div>
</form>

View File

@ -2,20 +2,14 @@
* Advanced Rate Search Page
*
* Complete search form with all filters and best options display
* Uses only ports available in CSV rates for origin/destination selection
*/
'use client';
import { useState, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import {
getAvailableOrigins,
getAvailableDestinations,
RoutePortInfo,
} from '@/lib/api/rates';
import { searchPorts, Port } from '@/lib/api/ports';
import dynamic from 'next/dynamic';
// Import dynamique pour éviter les erreurs SSR avec Leaflet
@ -98,60 +92,24 @@ export default function AdvancedSearchPage() {
const [destinationSearch, setDestinationSearch] = useState('');
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
const [selectedOriginPort, setSelectedOriginPort] = useState<Port | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<Port | null>(null);
// Fetch available origins from CSV rates
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'],
queryFn: getAvailableOrigins,
// Port autocomplete queries
const { data: originPortsData } = useQuery({
queryKey: ['ports', originSearch],
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
enabled: originSearch.length >= 2 && showOriginDropdown,
});
// Fetch available destinations based on selected origin
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin],
queryFn: () => getAvailableDestinations(searchForm.origin),
enabled: !!searchForm.origin,
const { data: destinationPortsData } = useQuery({
queryKey: ['ports', destinationSearch],
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
enabled: destinationSearch.length >= 2 && showDestinationDropdown,
});
// Filter origins based on search input
const filteredOrigins = (originsData?.origins || []).filter(port => {
if (!originSearch || originSearch.length < 1) return true;
const searchLower = originSearch.toLowerCase();
return (
port.code.toLowerCase().includes(searchLower) ||
port.name.toLowerCase().includes(searchLower) ||
port.city.toLowerCase().includes(searchLower) ||
port.countryName.toLowerCase().includes(searchLower)
);
});
// Filter destinations based on search input
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
if (!destinationSearch || destinationSearch.length < 1) return true;
const searchLower = destinationSearch.toLowerCase();
return (
port.code.toLowerCase().includes(searchLower) ||
port.name.toLowerCase().includes(searchLower) ||
port.city.toLowerCase().includes(searchLower) ||
port.countryName.toLowerCase().includes(searchLower)
);
});
// Reset destination when origin changes
useEffect(() => {
if (searchForm.origin && selectedDestinationPort) {
// Check if current destination is still valid for new origin
const isValidDestination = destinationsData?.destinations?.some(
d => d.code === searchForm.destination
);
if (!isValidDestination) {
setSearchForm(prev => ({ ...prev, destination: '' }));
setSelectedDestinationPort(null);
setDestinationSearch('');
}
}
}, [searchForm.origin, destinationsData]);
const originPorts = originPortsData?.ports || [];
const destinationPorts = destinationPortsData?.ports || [];
// Calculate total volume and weight
const calculateTotals = () => {
@ -230,51 +188,37 @@ export default function AdvancedSearchPage() {
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
<div className="grid grid-cols-2 gap-4">
{/* Origin Port with Autocomplete - Limited to CSV routes */}
{/* Origin Port with Autocomplete */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs"> Sélectionné</span>}
</label>
<div className="relative">
<input
type="text"
value={originSearch}
onChange={e => {
setOriginSearch(e.target.value);
setShowOriginDropdown(true);
// Clear selection if user modifies the input
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
setSelectedDestinationPort(null);
setDestinationSearch('');
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, origin: '' });
}
}}
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder="Rechercher un port d'origine..."
placeholder="ex: Rotterdam, Paris, FRPAR"
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'
}`}
/>
{isLoadingOrigins && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
)}
</div>
{showOriginDropdown && filteredOrigins.length > 0 && (
{showOriginDropdown && originPorts && originPorts.length > 0 && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
{originPorts.map((port: Port) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
setSearchForm({ ...searchForm, origin: port.code });
setOriginSearch(port.displayName);
setSelectedOriginPort(port);
setSelectedDestinationPort(null);
setDestinationSearch('');
setShowOriginDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
@ -285,60 +229,34 @@ export default function AdvancedSearchPage() {
</div>
</button>
))}
{filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
</div>
)}
</div>
)}
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">Aucun port d'origine trouvé pour "{originSearch}"</p>
</div>
)}
</div>
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
{/* Destination Port with Autocomplete */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs"> Sélectionné</span>}
</label>
<div className="relative">
<input
type="text"
value={destinationSearch}
onChange={e => {
setDestinationSearch(e.target.value);
setShowDestinationDropdown(true);
// Clear selection if user modifies the input
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null);
}
}}
onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin}
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
placeholder="ex: Shanghai, New York, CNSHA"
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
}`}
/>
{isLoadingDestinations && searchForm.origin && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
)}
</div>
{searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1">
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
</p>
)}
{showDestinationDropdown && filteredDestinations.length > 0 && (
{showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
{destinationPorts.map((port: Port) => (
<button
key={port.code}
type="button"
@ -356,23 +274,13 @@ export default function AdvancedSearchPage() {
</div>
</button>
))}
{filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
</div>
)}
</div>
)}
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">Aucune destination trouvée pour "{destinationSearch}"</p>
</div>
)}
</div>
</div>
{/* Carte interactive de la route maritime */}
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
{selectedOriginPort && selectedDestinationPort && (
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">
@ -384,12 +292,12 @@ export default function AdvancedSearchPage() {
</div>
<PortRouteMap
portA={{
lat: selectedOriginPort.latitude,
lng: selectedOriginPort.longitude!,
lat: selectedOriginPort.coordinates.latitude,
lng: selectedOriginPort.coordinates.longitude,
}}
portB={{
lat: selectedDestinationPort.latitude,
lng: selectedDestinationPort.longitude!,
lat: selectedDestinationPort.coordinates.latitude,
lng: selectedDestinationPort.coordinates.longitude,
}}
height="400px"
/>
@ -733,7 +641,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination}
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<Search className="h-5 w-5 mr-2" /> Rechercher les tarifs
🔍 Rechercher les tarifs
</button>
)}
</div>

View File

@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
interface BestOptions {
eco: CsvRateSearchResult;
@ -122,7 +121,7 @@ export default function SearchResultsPage() {
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
<div className="text-6xl mb-4"></div>
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
@ -149,13 +148,13 @@ export default function SearchResultsPage() {
</button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
<p className="text-gray-600 mb-4">
Aucun tarif ne correspond à votre recherche pour le trajet {origin} {destination}
</p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
<h4 className="font-semibold text-gray-900 mb-2">💡 Suggestions :</h4>
<ul className="text-sm text-gray-700 space-y-2">
<li>
<strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX,
@ -191,7 +190,7 @@ export default function SearchResultsPage() {
text: 'text-green-800',
button: 'bg-green-600 hover:bg-green-700',
},
icon: <DollarSign className="h-10 w-10 text-green-600" />,
icon: '💰',
badge: 'Le moins cher',
},
{
@ -203,7 +202,7 @@ export default function SearchResultsPage() {
text: 'text-blue-800',
button: 'bg-blue-600 hover:bg-blue-700',
},
icon: <Scale className="h-10 w-10 text-blue-600" />,
icon: '⚖️',
badge: 'Équilibré',
},
{
@ -215,7 +214,7 @@ export default function SearchResultsPage() {
text: 'text-purple-800',
button: 'bg-purple-600 hover:bg-purple-700',
},
icon: <Zap className="h-10 w-10 text-purple-600" />,
icon: '⚡',
badge: 'Le plus rapide',
},
];
@ -254,7 +253,7 @@ export default function SearchResultsPage() {
{bestOptions && (
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
<span className="text-3xl mr-3">🏆</span>
Meilleurs choix pour votre recherche
</h2>
@ -270,7 +269,7 @@ export default function SearchResultsPage() {
<div className={`p-6 ${card.colors.bg}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<span>{card.icon}</span>
<span className="text-4xl">{card.icon}</span>
<div>
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
@ -367,7 +366,7 @@ export default function SearchResultsPage() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600">
<span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
{result.hasSurcharges && <span className="text-orange-600"> Surcharges applicables</span>}
</div>
<button
onClick={() => {

View File

@ -11,7 +11,6 @@ import { useQuery } from '@tanstack/react-query';
import Image from 'next/image';
import { searchRates } from '@/lib/api';
import { searchPorts, Port } from '@/lib/api/ports';
import { Search, Leaf, Package } from 'lucide-react';
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
@ -123,9 +122,9 @@ export default function RateSearchPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Recherche de Tarifs Maritime</h1>
<h1 className="text-2xl font-bold text-gray-900">Search Shipping Rates</h1>
<p className="text-sm text-gray-500 mt-1">
Comparez les tarifs de plusieurs transporteurs en temps réel
Compare rates from multiple carriers in real-time
</p>
</div>
@ -136,7 +135,7 @@ export default function RateSearchPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Origin Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Port d'origine *</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Origin Port *</label>
<input
type="text"
required
@ -147,7 +146,7 @@ export default function RateSearchPage() {
setSearchForm({ ...searchForm, originPort: '' });
}
}}
placeholder="ex : Rotterdam, Shanghai"
placeholder="e.g., Rotterdam, Shanghai"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{originPorts && originPorts.length > 0 && (
@ -175,7 +174,7 @@ export default function RateSearchPage() {
{/* Destination Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Port de destination *
Destination Port *
</label>
<input
type="text"
@ -187,7 +186,7 @@ export default function RateSearchPage() {
setSearchForm({ ...searchForm, destinationPort: '' });
}
}}
placeholder="ex : Los Angeles, Hambourg"
placeholder="e.g., Los Angeles, Hamburg"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{destinationPorts && destinationPorts.length > 0 && (
@ -217,7 +216,7 @@ export default function RateSearchPage() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type de conteneur *
Container Type *
</label>
<select
value={searchForm.containerType}
@ -236,7 +235,7 @@ export default function RateSearchPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Quantité *</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Quantity *</label>
<input
type="number"
min="1"
@ -251,7 +250,7 @@ export default function RateSearchPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date de départ *
Departure Date *
</label>
<input
type="date"
@ -265,7 +264,6 @@ export default function RateSearchPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
<select
value={searchForm.mode}
onChange={e => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
@ -287,7 +285,7 @@ export default function RateSearchPage() {
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
Marchandises dangereuses (manutention spéciale requise)
Hazardous Materials (requires special handling)
</label>
</div>
@ -301,12 +299,12 @@ export default function RateSearchPage() {
{isSearching ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Recherche en cours...
Searching...
</>
) : (
<>
<Search className="h-5 w-5 mr-2" />
Rechercher des tarifs
<span className="mr-2">🔍</span>
Search Rates
</>
)}
</button>
@ -317,7 +315,7 @@ export default function RateSearchPage() {
{/* Error */}
{searchError && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">La recherche de tarifs a échoué. Veuillez réessayer.</div>
<div className="text-sm text-red-800">Failed to search rates. Please try again.</div>
</div>
)}
@ -328,20 +326,20 @@ export default function RateSearchPage() {
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Trier par</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Sort By</h3>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="price">Prix (croissant)</option>
<option value="transitTime">Temps de transit</option>
<option value="co2">Émissions CO2</option>
<option value="price">Price (Low to High)</option>
<option value="transitTime">Transit Time</option>
<option value="co2">CO2 Emissions</option>
</select>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fourchette de prix (USD)</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Price Range (USD)</h3>
<div className="space-y-2">
<input
type="range"
@ -353,14 +351,14 @@ export default function RateSearchPage() {
className="w-full"
/>
<div className="text-sm text-gray-600">
Jusqu'à {priceRange[1].toLocaleString()} $
Up to ${priceRange[1].toLocaleString()}
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Temps de transit max (jours)
Max Transit Time (days)
</h3>
<div className="space-y-2">
<input
@ -371,13 +369,13 @@ export default function RateSearchPage() {
onChange={e => setTransitTimeMax(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-sm text-gray-600">{transitTimeMax} jours</div>
<div className="text-sm text-gray-600">{transitTimeMax} days</div>
</div>
</div>
{availableCarriers.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Transporteurs</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Carriers</h3>
<div className="space-y-2">
{availableCarriers.map(carrier => (
<label key={carrier} className="flex items-center">
@ -400,7 +398,8 @@ export default function RateSearchPage() {
<div className="lg:col-span-3 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{filteredAndSortedQuotes.length} tarif{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
{filteredAndSortedQuotes.length} Rate
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} Found
</h2>
</div>
@ -419,9 +418,9 @@ export default function RateSearchPage() {
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun tarif trouvé</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">No rates found</h3>
<p className="mt-1 text-sm text-gray-500">
Essayez d'ajuster vos filtres ou vos critères de recherche
Try adjusting your filters or search criteria
</p>
</div>
) : (
@ -468,19 +467,19 @@ export default function RateSearchPage() {
{/* Route Info */}
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<div className="text-xs text-gray-500 uppercase">Départ</div>
<div className="text-xs text-gray-500 uppercase">Departure</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.etd).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Temps de transit</div>
<div className="text-xs text-gray-500 uppercase">Transit Time</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{quote.route.transitDays} jours
{quote.route.transitDays} days
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Arrivée</div>
<div className="text-xs text-gray-500 uppercase">Arrival</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.eta).toLocaleDateString()}
</div>
@ -531,14 +530,14 @@ export default function RateSearchPage() {
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
{quote.co2Emissions && (
<div className="flex items-center">
<Leaf className="h-4 w-4 mr-1 text-green-500" />
<span className="mr-1">🌱</span>
{quote.co2Emissions.value} kg CO2
</div>
)}
{quote.availability && (
<div className="flex items-center">
<Package className="h-4 w-4 mr-1 text-blue-500" />
{quote.availability} conteneurs disponibles
<span className="mr-1">📦</span>
{quote.availability} containers available
</div>
)}
</div>
@ -546,7 +545,7 @@ export default function RateSearchPage() {
{/* Surcharges */}
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
<div className="mt-4 text-sm">
<div className="text-gray-500 mb-2">Surcharges incluses :</div>
<div className="text-gray-500 mb-2">Includes surcharges:</div>
<div className="flex flex-wrap gap-2">
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
<span
@ -566,7 +565,7 @@ export default function RateSearchPage() {
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
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"
>
Réserver
Book Now
</a>
</div>
</div>
@ -592,9 +591,10 @@ export default function RateSearchPage() {
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">Rechercher des tarifs maritimes</h3>
<h3 className="mt-4 text-lg font-medium text-gray-900">Search for Shipping Rates</h3>
<p className="mt-2 text-sm text-gray-500">
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de plusieurs transporteurs
Enter your origin, destination, and container details to compare rates from multiple
carriers
</p>
</div>
)}

View File

@ -13,7 +13,6 @@ import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
import ExportButton from '@/components/ExportButton';
export default function UsersManagementPage() {
const router = useRouter();
@ -54,7 +53,7 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.');
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
setShowInviteModal(false);
setInviteForm({
email: '',
@ -65,7 +64,7 @@ export default function UsersManagementPage() {
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation');
setError(err.response?.data?.message || 'Failed to send invitation');
setTimeout(() => setError(''), 5000);
},
});
@ -76,11 +75,11 @@ export default function UsersManagementPage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Rôle mis à jour avec succès');
setSuccess('Role updated successfully');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
setError(err.response?.data?.message || 'Failed to update role');
setTimeout(() => setError(''), 5000);
},
});
@ -92,11 +91,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Statut de l\'utilisateur mis à jour avec succès');
setSuccess('User status updated successfully');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
setError(err.response?.data?.message || 'Failed to update user status');
setTimeout(() => setError(''), 5000);
},
});
@ -106,11 +105,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Utilisateur supprimé avec succès');
setSuccess('User deleted successfully');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur');
setError(err.response?.data?.message || 'Failed to delete user');
setTimeout(() => setError(''), 5000);
},
});
@ -144,7 +143,7 @@ export default function UsersManagementPage() {
const handleToggleActive = (userId: string, isActive: boolean) => {
if (
window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)
window.confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this user?`)
) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
@ -152,7 +151,7 @@ export default function UsersManagementPage() {
const handleDelete = (userId: string) => {
if (
window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')
window.confirm('Are you sure you want to delete this user? This action cannot be undone.')
) {
deleteMutation.mutate(userId);
}
@ -180,17 +179,17 @@ export default function UsersManagementPage() {
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
<p className="mt-1 text-sm text-amber-700">
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Upgrade your subscription to invite more users.
</p>
<div className="mt-3">
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
>
Mettre à niveau l'abonnement
Upgrade Subscription
</Link>
</div>
</div>
@ -207,14 +206,14 @@ export default function UsersManagementPage() {
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span className="text-sm text-blue-800">
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
</span>
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Gérer l'abonnement
Manage Subscription
</Link>
</div>
</div>
@ -223,37 +222,16 @@ export default function UsersManagementPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
<p className="text-sm text-gray-500 mt-1">Gérez les membres de l'équipe et leurs permissions</p>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={users?.users || []}
filename="utilisateurs"
columns={[
{ key: 'firstName', label: 'Prénom' },
{ key: 'lastName', label: 'Nom' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Rôle', format: (v) => {
const labels: Record<string, string> = {
ADMIN: 'Administrateur',
MANAGER: 'Manager',
USER: 'Utilisateur',
VIEWER: 'Lecteur',
};
return labels[v] || v;
}},
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
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"
>
<span className="mr-2">+</span>
Inviter un utilisateur
Invite User
</button>
) : (
<Link
@ -261,11 +239,10 @@ export default function UsersManagementPage() {
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span>
Mettre à niveau
Upgrade to Invite
</Link>
)}
</div>
</div>
{success && (
<div className="rounded-md bg-green-50 p-4">
@ -284,7 +261,7 @@ export default function UsersManagementPage() {
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
Chargement des utilisateurs...
Loading users...
</div>
) : users?.users && users.users.length > 0 ? (
<div className="overflow-x-auto overflow-y-visible">
@ -292,19 +269,19 @@ export default function UsersManagementPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisateur
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rôle
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date de création
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
@ -361,7 +338,7 @@ export default function UsersManagementPage() {
: 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Actif' : 'Inactif'}
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -409,8 +386,8 @@ export default function UsersManagementPage() {
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button
@ -513,7 +490,7 @@ export default function UsersManagementPage() {
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
<h3 className="text-lg font-medium text-gray-900">Invite User</h3>
<button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
@ -533,7 +510,7 @@ export default function UsersManagementPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Prénom *
First Name *
</label>
<input
type="text"
@ -544,7 +521,7 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Nom *</label>
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
<input
type="text"
required
@ -556,7 +533,7 @@ export default function UsersManagementPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
<label className="block text-sm font-medium text-gray-700">Email *</label>
<input
type="email"
required
@ -567,20 +544,20 @@ export default function UsersManagementPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
<label className="block text-sm font-medium text-gray-700">Role *</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="USER">Utilisateur</option>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
<option value="VIEWER">Lecteur</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="VIEWER">Viewer</option>
</select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">
Seuls les administrateurs peuvent attribuer le rôle ADMIN
Only platform administrators can assign the ADMIN role
</p>
)}
</div>
@ -591,14 +568,14 @@ export default function UsersManagementPage() {
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
>
{inviteMutation.isPending ? 'Envoi en cours...' : 'Envoyer l\'invitation'}
{inviteMutation.isPending ? 'Inviting...' : 'Send Invitation'}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Annuler
Cancel
</button>
</div>
</form>

View File

@ -2,181 +2,107 @@
* Track & Trace Page
*
* Allows users to track their shipments by entering tracking numbers
* and selecting the carrier. Includes search history and vessel position map.
* and selecting the carrier. Redirects to carrier's tracking page.
*/
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Search,
Package,
FileText,
ClipboardList,
Lightbulb,
History,
MapPin,
X,
Clock,
Ship,
ExternalLink,
Maximize2,
Minimize2,
Globe,
Anchor,
} from 'lucide-react';
// Search history item type
interface SearchHistoryItem {
id: string;
trackingNumber: string;
carrierId: string;
carrierName: string;
timestamp: Date;
}
// Carrier tracking URLs with official brand colors
// Carrier tracking URLs - the tracking number will be appended
const carriers = [
{
id: 'maersk',
name: 'Maersk',
color: '#00243D', // Maersk dark blue
textColor: 'text-white',
logo: '🚢',
trackingUrl: 'https://www.maersk.com/tracking/',
placeholder: 'Ex: MSKU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/maersk.svg',
description: 'Container or B/L number',
},
{
id: 'msc',
name: 'MSC',
color: '#002B5C', // MSC blue
textColor: 'text-white',
logo: '🛳️',
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
placeholder: 'Ex: MSCU1234567',
description: 'N° conteneur, B/L ou réservation',
logo: '/assets/logos/carriers/msc.svg',
description: 'Container, B/L or Booking number',
},
{
id: 'cma-cgm',
name: 'CMA CGM',
color: '#E30613', // CMA CGM red
textColor: 'text-white',
logo: '⚓',
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
placeholder: 'Ex: CMAU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cmacgm.svg',
description: 'Container or B/L number',
},
{
id: 'hapag-lloyd',
name: 'Hapag-Lloyd',
color: '#FF6600', // Hapag orange
textColor: 'text-white',
logo: '🔷',
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
placeholder: 'Ex: HLCU1234567',
description: 'N° conteneur',
logo: '/assets/logos/carriers/hapag.svg',
description: 'Container number',
},
{
id: 'cosco',
name: 'COSCO',
color: '#003A70', // COSCO blue
textColor: 'text-white',
logo: '🌊',
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
placeholder: 'Ex: COSU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cosco.svg',
description: 'Container or B/L number',
},
{
id: 'one',
name: 'ONE',
color: '#FF00FF', // ONE magenta
textColor: 'text-white',
name: 'ONE (Ocean Network Express)',
logo: '🟣',
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
placeholder: 'Ex: ONEU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/one.svg',
description: 'Container or B/L number',
},
{
id: 'evergreen',
name: 'Evergreen',
color: '#006633', // Evergreen green
textColor: 'text-white',
logo: '🌲',
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
placeholder: 'Ex: EGHU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/evergreen.svg',
description: 'Container or B/L number',
},
{
id: 'yangming',
name: 'Yang Ming',
color: '#FFD700', // Yang Ming yellow
textColor: 'text-gray-900',
logo: '🟡',
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
description: 'N° conteneur',
logo: '/assets/logos/carriers/yangming.svg',
description: 'Container number',
},
{
id: 'zim',
name: 'ZIM',
color: '#1E3A8A', // ZIM blue
textColor: 'text-white',
logo: '🔵',
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
placeholder: 'Ex: ZIMU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/zim.svg',
description: 'Container or B/L number',
},
{
id: 'hmm',
name: 'HMM',
color: '#E65100', // HMM orange
textColor: 'text-white',
name: 'HMM (Hyundai)',
logo: '🟠',
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
placeholder: 'Ex: HDMU1234567',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/hmm.svg',
description: 'Container or B/L number',
},
];
// Local storage keys
const HISTORY_KEY = 'xpeditis_track_history';
export default function TrackTracePage() {
const [trackingNumber, setTrackingNumber] = useState('');
const [selectedCarrier, setSelectedCarrier] = useState('');
const [error, setError] = useState('');
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [showMap, setShowMap] = useState(false);
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const [isMapLoading, setIsMapLoading] = useState(true);
// Load history from localStorage on mount
useEffect(() => {
const savedHistory = localStorage.getItem(HISTORY_KEY);
if (savedHistory) {
try {
const parsed = JSON.parse(savedHistory);
setSearchHistory(parsed.map((item: any) => ({
...item,
timestamp: new Date(item.timestamp)
})));
} catch (e) {
console.error('Failed to parse search history:', e);
}
}
}, []);
// Save to localStorage
const saveHistory = (history: SearchHistoryItem[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
setSearchHistory(history);
};
const handleTrack = () => {
// Validation
if (!trackingNumber.trim()) {
setError('Veuillez entrer un numéro de tracking');
return;
@ -188,43 +114,15 @@ export default function TrackTracePage() {
setError('');
// Find the carrier and build the tracking URL
const carrier = carriers.find(c => c.id === selectedCarrier);
if (carrier) {
// Add to history
const newHistoryItem: SearchHistoryItem = {
id: Date.now().toString(),
trackingNumber: trackingNumber.trim(),
carrierId: carrier.id,
carrierName: carrier.name,
timestamp: new Date(),
};
// Keep only last 10 unique searches
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
)].slice(0, 10);
saveHistory(updatedHistory);
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
// Open in new tab
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
}
};
const handleHistoryClick = (item: SearchHistoryItem) => {
setTrackingNumber(item.trackingNumber);
setSelectedCarrier(item.carrierId);
};
const handleDeleteHistory = (id: string) => {
const updatedHistory = searchHistory.filter(h => h.id !== id);
saveHistory(updatedHistory);
};
const handleClearHistory = () => {
saveHistory([]);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleTrack();
@ -233,25 +131,11 @@ export default function TrackTracePage() {
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR');
};
return (
<div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1>
<h1 className="text-3xl font-bold text-gray-900">Track & Trace</h1>
<p className="mt-2 text-gray-600">
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
</p>
@ -261,15 +145,15 @@ export default function TrackTracePage() {
<Card className="bg-white shadow-lg border-blue-100">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Search className="h-5 w-5 text-blue-600" />
<span className="text-2xl">🔍</span>
Rechercher une expédition
</CardTitle>
<CardDescription>
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */}
{/* Carrier Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Sélectionnez le transporteur
@ -283,20 +167,14 @@ export default function TrackTracePage() {
setSelectedCarrier(carrier.id);
setError('');
}}
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all hover:scale-105 ${
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
selectedCarrier === carrier.id
? 'border-blue-500 shadow-lg ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
}`}
>
{/* Carrier logo/badge with brand color */}
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
style={{ backgroundColor: carrier.color }}
>
{carrier.name.length <= 3 ? carrier.name : carrier.name.substring(0, 2).toUpperCase()}
</div>
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">{carrier.name}</span>
<span className="text-2xl mb-1">{carrier.logo}</span>
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
</button>
))}
</div>
@ -319,42 +197,22 @@ export default function TrackTracePage() {
}}
onKeyPress={handleKeyPress}
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
className="text-lg font-mono border-gray-300 focus:border-blue-500"
/>
{selectedCarrierData && (
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
)}
</div>
{/* US 5.2: Harmonized button color */}
<Button
onClick={handleTrack}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
>
<Search className="mr-2 h-5 w-5" />
<span className="mr-2">🔍</span>
Rechercher
</Button>
</div>
</div>
{/* Action Button - Map */}
<div className="flex flex-wrap gap-3 pt-2">
<Button
variant={showMap ? "default" : "outline"}
onClick={() => {
setShowMap(!showMap);
if (!showMap) setIsMapLoading(true);
}}
className={showMap
? "bg-blue-600 hover:bg-blue-700 text-white"
: "text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700"
}
>
<Globe className="mr-2 h-4 w-4" />
{showMap ? 'Masquer la carte maritime' : 'Afficher la carte maritime'}
</Button>
</div>
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
@ -364,221 +222,12 @@ export default function TrackTracePage() {
</CardContent>
</Card>
{/* Vessel Position Map - Large immersive display */}
{showMap && (
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}>
{/* Map Header */}
<div className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}>
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Globe className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold">Carte Maritime Mondiale</h3>
<p className="text-blue-100 text-sm">Position des navires en temps réel</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Fullscreen Toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsMapFullscreen(!isMapFullscreen)}
className="text-white hover:bg-white/20"
>
{isMapFullscreen ? (
<>
<Minimize2 className="h-4 w-4 mr-2" />
Réduire
</>
) : (
<>
<Maximize2 className="h-4 w-4 mr-2" />
Plein écran
</>
)}
</Button>
{/* Close Button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowMap(false);
setIsMapFullscreen(false);
}}
className="text-white hover:bg-white/20"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Map Container */}
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
{/* Loading State */}
{isMapLoading && (
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
<div className="text-center">
<div className="relative">
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
<p className="mt-4 text-blue-700 font-medium">Chargement de la carte...</p>
<p className="text-blue-500 text-sm">Connexion à MarineTraffic</p>
</div>
</div>
)}
{/* MarineTraffic Map */}
<iframe
src="https://www.marinetraffic.com/en/ais/embed/zoom:3/centery:25/centerx:0/maptype:4/shownames:true/mmsi:0/shipid:0/fleet:/fleet_id:/vtypes:/showmenu:true/remember:false"
className="w-full h-full border-0"
title="Carte maritime en temps réel"
loading="lazy"
onLoad={() => setIsMapLoading(false)}
/>
{/* Map Legend Overlay */}
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}>
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
<Anchor className="h-4 w-4 text-blue-600" />
Légende
</h4>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-gray-600">Cargos</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-gray-600">Tankers</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-gray-600">Passagers</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-gray-600">High Speed</span>
</div>
</div>
</div>
{/* Quick Stats Overlay */}
<div className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">90K+</p>
<p className="text-gray-500 text-xs">Navires actifs</p>
</div>
<div className="w-px h-10 bg-gray-200" />
<div className="text-center">
<p className="text-2xl font-bold text-green-600">3,500+</p>
<p className="text-gray-500 text-xs">Ports mondiaux</p>
</div>
</div>
</div>
</div>
{/* Map Footer */}
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<p className="text-xs text-gray-500 flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
Données fournies par MarineTraffic - Mise à jour en temps réel
</p>
<a
href="https://www.marinetraffic.com"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
>
Ouvrir sur MarineTraffic
<ExternalLink className="h-3 w-3" />
</a>
</div>
</Card>
</div>
)}
{/* Search History */}
<Card className="bg-white shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<History className="h-5 w-5 text-gray-600" />
Historique des recherches
</CardTitle>
{searchHistory.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearHistory}
className="text-gray-500 hover:text-red-600 text-xs"
>
Effacer tout
</Button>
)}
</div>
</CardHeader>
<CardContent>
{searchHistory.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" />
<p className="text-sm">Aucune recherche récente</p>
<p className="text-xs text-gray-400 mt-1">Vos recherches apparaîtront ici</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{searchHistory.map(item => {
const carrier = carriers.find(c => c.id === item.carrierId);
return (
<div
key={item.id}
className="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:bg-gray-50 group cursor-pointer transition-colors"
onClick={() => handleHistoryClick(item)}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${carrier?.textColor || 'text-white'}`}
style={{ backgroundColor: carrier?.color || '#666' }}
>
{item.carrierName.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p>
<p className="text-xs text-gray-500">{item.carrierName} {formatTimeAgo(item.timestamp)}</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteHistory(item.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-50 rounded transition-opacity"
>
<X className="h-4 w-4 text-gray-400 hover:text-red-500" />
</button>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Help Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
<span>📦</span>
Numéro de conteneur
</CardTitle>
</CardHeader>
@ -593,14 +242,14 @@ export default function TrackTracePage() {
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-600" />
<span>📋</span>
Connaissement (B/L)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
Le numéro de connaissement (Bill of Lading) est fourni par le transporteur lors de la confirmation de réservation.
Le format varie selon le transporteur.
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
Format variable selon le carrier.
</p>
</CardContent>
</Card>
@ -608,8 +257,8 @@ export default function TrackTracePage() {
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-blue-600" />
Référence de réservation
<span>📝</span>
Référence de booking
</CardTitle>
</CardHeader>
<CardContent>
@ -623,7 +272,7 @@ export default function TrackTracePage() {
{/* Info Box */}
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
<span className="text-xl">💡</span>
<div>
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
<p className="text-sm text-blue-700 mt-1">

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Shield, ClipboardList, DollarSign, Pencil, Lightbulb, Plus } from 'lucide-react';
const clausesICC = [
{
@ -65,7 +64,7 @@ export default function AssurancePage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Shield className="w-10 h-10 text-blue-600" />
<span className="text-4xl">🛡</span>
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -89,7 +88,7 @@ export default function AssurancePage() {
{/* ICC Clauses */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Clauses ICC (Institute Cargo Clauses)</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
<div className="space-y-4">
{clausesICC.map((clause) => (
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
@ -139,7 +138,7 @@ export default function AssurancePage() {
{/* Valeur assurée */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Calcul de la Valeur Assurée</h3>
<h3 className="font-semibold text-gray-900 mb-3">💰 Calcul de la Valeur Assurée</h3>
<div className="bg-white p-4 rounded-lg border">
<div className="text-center mb-4">
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
@ -166,7 +165,7 @@ export default function AssurancePage() {
{/* Extensions */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Plus className="w-5 h-5" /> Extensions de Garantie</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4"> Extensions de Garantie</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{extensionsGaranties.map((ext) => (
<Card key={ext.name} className="bg-white">
@ -182,7 +181,7 @@ export default function AssurancePage() {
{/* Process */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Pencil className="w-5 h-5" /> En Cas de Sinistre</h3>
<h3 className="font-semibold text-gray-900 mb-3">📝 En Cas de Sinistre</h3>
<ol className="list-decimal list-inside space-y-3 text-gray-700">
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
<li><strong>Préserver</strong> : Ne pas modifier l&apos;état des marchandises (photos, témoins)</li>
@ -196,7 +195,7 @@ export default function AssurancePage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Calculator, Ruler, ClipboardList, Banknote, BarChart3, Lightbulb, Scale } from 'lucide-react';
const surcharges = [
{
@ -87,7 +86,7 @@ export default function CalculFretPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Calculator className="w-10 h-10 text-blue-600" />
<span className="text-4xl">🧮</span>
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -99,7 +98,7 @@ export default function CalculFretPage() {
{/* Base Calculation */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Principes de Base</h3>
<h3 className="font-semibold text-blue-900 mb-3">📐 Principes de Base</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
@ -122,7 +121,7 @@ export default function CalculFretPage() {
{/* Weight Calculation */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Scale className="w-5 h-5" /> Poids Taxable (LCL)</h3>
<h3 className="font-semibold text-gray-900 mb-3"> Poids Taxable (LCL)</h3>
<div className="bg-white p-4 rounded-lg border mb-4">
<div className="text-center">
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
@ -158,7 +157,7 @@ export default function CalculFretPage() {
{/* Surcharges */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Surcharges Courantes</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{surcharges.map((sur) => (
<Card key={sur.code} className="bg-white">
@ -181,7 +180,7 @@ export default function CalculFretPage() {
{/* Additional fees */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Banknote className="w-5 h-5" /> Frais Additionnels</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -211,7 +210,7 @@ export default function CalculFretPage() {
{/* Example calculation */}
<Card className="mt-8 bg-green-50 border-green-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-green-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Exemple de Devis FCL</h3>
<h3 className="font-semibold text-green-900 mb-3">📊 Exemple de Devis FCL</h3>
<div className="bg-white p-4 rounded-lg border">
<p className="text-sm text-gray-600 mb-3">Conteneur 40&apos; Shanghai Le Havre</p>
<div className="space-y-2 font-mono text-sm">
@ -255,7 +254,7 @@ export default function CalculFretPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Demandez des devis &quot;All-in&quot; pour éviter les surprises de surcharges</li>
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Package, PackageOpen, Truck, Cylinder, Snowflake, type LucideIcon } from 'lucide-react';
const containers = [
{
@ -23,7 +22,7 @@ const containers = [
tare: '2,300 kg',
},
usage: 'Marchandises générales sèches',
icon: Package,
icon: '📦',
},
{
type: '40\' Standard (40\' DRY)',
@ -39,7 +38,7 @@ const containers = [
tare: '3,800 kg',
},
usage: 'Marchandises générales, cargo volumineux',
icon: Package,
icon: '📦',
},
{
type: '40\' High Cube (40\' HC)',
@ -55,7 +54,7 @@ const containers = [
tare: '4,020 kg',
},
usage: 'Cargo léger mais volumineux',
icon: Package,
icon: '📦',
},
{
type: 'Reefer (Réfrigéré)',
@ -71,7 +70,7 @@ const containers = [
temperature: '-30°C à +30°C',
},
usage: 'Produits périssables, pharmaceutiques',
icon: Snowflake,
icon: '❄️',
},
{
type: 'Open Top',
@ -87,7 +86,7 @@ const containers = [
tare: '2,400 kg / 4,100 kg',
},
usage: 'Cargo hors gabarit en hauteur, machinerie',
icon: PackageOpen,
icon: '📭',
},
{
type: 'Flat Rack',
@ -103,7 +102,7 @@ const containers = [
tare: '2,700 kg / 4,700 kg',
},
usage: 'Cargo très lourd ou surdimensionné',
icon: Truck,
icon: '🚛',
},
{
type: 'Tank Container',
@ -119,7 +118,7 @@ const containers = [
tare: '3,500 kg',
},
usage: 'Liquides, gaz, produits chimiques',
icon: Cylinder,
icon: '🛢️',
},
];
@ -149,7 +148,7 @@ export default function ConteneursPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Package className="w-10 h-10 text-blue-600" />
<span className="text-4xl">📦</span>
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -190,7 +189,7 @@ export default function ConteneursPage() {
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<container.icon className="w-6 h-6 text-blue-600" />
<span className="text-2xl">{container.icon}</span>
<div>
<span className="text-lg">{container.type}</span>
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
@ -261,35 +260,35 @@ export default function ConteneursPage() {
<CardContent>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Package className="w-5 h-5 text-green-700 mt-0.5" />
<span className="text-xl">📦</span>
<div>
<p className="font-medium text-green-900">Marchandises générales</p>
<p className="text-sm text-green-800"> 20&apos; ou 40&apos; Standard (DRY)</p>
</div>
</div>
<div className="flex items-start gap-3">
<Snowflake className="w-5 h-5 text-green-700 mt-0.5" />
<span className="text-xl"></span>
<div>
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
<p className="text-sm text-green-800"> Reefer 20&apos; ou 40&apos;</p>
</div>
</div>
<div className="flex items-start gap-3">
<PackageOpen className="w-5 h-5 text-green-700 mt-0.5" />
<span className="text-xl">📭</span>
<div>
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
<p className="text-sm text-green-800"> Open Top</p>
</div>
</div>
<div className="flex items-start gap-3">
<Truck className="w-5 h-5 text-green-700 mt-0.5" />
<span className="text-xl">🚛</span>
<div>
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
<p className="text-sm text-green-800"> Flat Rack</p>
</div>
</div>
<div className="flex items-start gap-3">
<Cylinder className="w-5 h-5 text-green-700 mt-0.5" />
<span className="text-xl">🛢</span>
<div>
<p className="font-medium text-green-900">Liquides en vrac</p>
<p className="text-sm text-green-800"> Tank Container ou Flexitank</p>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { FileText, ClipboardList, FileStack, Package, Receipt, Factory, type LucideIcon } from 'lucide-react';
const documents = [
{
@ -19,7 +18,7 @@ const documents = [
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
],
importance: 'Critique',
icon: FileText,
icon: '📄',
},
{
name: 'Sea Waybill',
@ -30,7 +29,7 @@ const documents = [
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
],
importance: 'Important',
icon: ClipboardList,
icon: '📋',
},
{
name: 'Manifest',
@ -41,7 +40,7 @@ const documents = [
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
],
importance: 'Obligatoire',
icon: FileStack,
icon: '📑',
},
{
name: 'Packing List',
@ -52,7 +51,7 @@ const documents = [
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
],
importance: 'Important',
icon: Package,
icon: '📦',
},
{
name: 'Commercial Invoice',
@ -63,7 +62,7 @@ const documents = [
{ name: 'Définitive', desc: 'Document final de facturation' },
],
importance: 'Critique',
icon: Receipt,
icon: '🧾',
},
{
name: 'Certificate of Origin',
@ -75,7 +74,7 @@ const documents = [
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
],
importance: 'Selon destination',
icon: Factory,
icon: '🏭',
},
];
@ -121,7 +120,7 @@ export default function DocumentsTransportPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<ClipboardList className="w-10 h-10 text-blue-600" />
<span className="text-4xl">📋</span>
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -150,7 +149,7 @@ export default function DocumentsTransportPage() {
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<doc.icon className="w-6 h-6 text-blue-600" />
<span className="text-2xl">{doc.icon}</span>
<div>
<span className="text-lg">{doc.name}</span>
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ShieldCheck, ClipboardList, FileText, DollarSign, AlertTriangle } from 'lucide-react';
const regimesDouaniers = [
{
@ -98,7 +97,7 @@ export default function DouanesPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<ShieldCheck className="w-10 h-10 text-blue-600" />
<span className="text-4xl">🛃</span>
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -135,7 +134,7 @@ export default function DouanesPage() {
{/* Régimes douaniers */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Régimes Douaniers</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{regimesDouaniers.map((regime) => (
<Card key={regime.code} className="bg-white">
@ -157,7 +156,7 @@ export default function DouanesPage() {
{/* Documents requis */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -180,7 +179,7 @@ export default function DouanesPage() {
{/* Droits et taxes */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Droits et Taxes</h3>
<h3 className="font-semibold text-gray-900 mb-3">💰 Droits et Taxes</h3>
<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">Droits de douane</h4>
@ -204,7 +203,7 @@ export default function DouanesPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Points d&apos;Attention</h3>
<h3 className="font-semibold text-amber-900 mb-3"> Points d&apos;Attention</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Toujours vérifier le classement tarifaire avant l&apos;importation</li>
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ClipboardList, Hash, Package, FileText, Tag, Shuffle, Lightbulb, AlertTriangle } from 'lucide-react';
const classesIMDG = [
{
@ -111,7 +110,7 @@ export default function IMDGPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<AlertTriangle className="w-10 h-10 text-orange-500" />
<span className="text-4xl"></span>
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -124,7 +123,7 @@ export default function IMDGPage() {
{/* Key Info */}
<Card className="bg-red-50 border-red-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Responsabilités de l&apos;Expéditeur</h3>
<h3 className="font-semibold text-red-900 mb-3"> Responsabilités de l&apos;Expéditeur</h3>
<ul className="list-disc list-inside space-y-2 text-red-800">
<li>Classer correctement la marchandise selon le Code IMDG</li>
<li>Utiliser des emballages homologués UN</li>
@ -137,7 +136,7 @@ export default function IMDGPage() {
{/* Classes */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Les 9 Classes de Danger</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classesIMDG.map((cls) => (
<Card key={cls.class} className="bg-white overflow-hidden">
@ -172,7 +171,7 @@ export default function IMDGPage() {
{/* UN Number */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Hash className="w-5 h-5" /> Numéro ONU (UN Number)</h3>
<h3 className="font-semibold text-gray-900 mb-3">🔢 Numéro ONU (UN Number)</h3>
<p className="text-gray-600 mb-4">
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
@ -196,7 +195,7 @@ export default function IMDGPage() {
{/* Packaging Groups */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" /> Groupes d&apos;Emballage</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d&apos;Emballage</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -220,7 +219,7 @@ export default function IMDGPage() {
{/* Documents */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -241,7 +240,7 @@ export default function IMDGPage() {
{/* Labeling */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Tag className="w-5 h-5" /> Marquage et Étiquetage</h3>
<h3 className="font-semibold text-gray-900 mb-3">🏷 Marquage et Étiquetage</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Colis</h4>
@ -268,7 +267,7 @@ export default function IMDGPage() {
{/* Segregation */}
<Card className="mt-8 bg-orange-50 border-orange-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><Shuffle className="w-5 h-5" /> Ségrégation</h3>
<h3 className="font-semibold text-orange-900 mb-3">🔀 Ségrégation</h3>
<p className="text-orange-800 mb-3">
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
Le Code IMDG définit des règles strictes de ségrégation :
@ -297,7 +296,7 @@ export default function IMDGPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Vérifier l&apos;acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ScrollText, Ship, ArrowUpFromLine, ArrowDownToLine } from 'lucide-react';
const incoterms = [
{
@ -120,7 +119,7 @@ export default function IncotermsPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<ScrollText className="w-10 h-10 text-blue-600" />
<span className="text-4xl">📜</span>
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -147,11 +146,8 @@ export default function IncotermsPage() {
{categories.map((category) => (
<div key={category} className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<span className="flex items-center gap-2">
{category === 'Maritime' ? <><Ship className="w-5 h-5" /> Incoterms Maritimes</> :
category === 'Départ' ? <><ArrowUpFromLine className="w-5 h-5" /> Incoterms de Départ</> :
<><ArrowDownToLine className="w-5 h-5" /> Incoterms d&apos;Arrivée</>}
</span>
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{incoterms

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Package, Truck, Scale } from 'lucide-react';
export default function LclVsFclPage() {
return (
@ -27,7 +26,7 @@ export default function LclVsFclPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Scale className="w-10 h-10 text-blue-600" />
<span className="text-4xl"></span>
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -41,7 +40,7 @@ export default function LclVsFclPage() {
<Card className="bg-blue-50 border-blue-200">
<CardHeader>
<CardTitle className="text-blue-900 flex items-center gap-2">
<Package className="w-6 h-6" />
<span className="text-2xl">📦</span>
LCL - Groupage
</CardTitle>
</CardHeader>
@ -65,7 +64,7 @@ export default function LclVsFclPage() {
<Card className="bg-green-50 border-green-200">
<CardHeader>
<CardTitle className="text-green-900 flex items-center gap-2">
<Truck className="w-6 h-6" />
<span className="text-2xl">🚛</span>
FCL - Complet
</CardTitle>
</CardHeader>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { CreditCard, RefreshCw, Users, ClipboardList, FileText, Calendar, DollarSign, ScrollText, Lightbulb, AlertTriangle } from 'lucide-react';
const typesLC = [
{
@ -94,7 +93,7 @@ export default function LettreCreditPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="w-10 h-10 text-blue-600" />
<span className="text-4xl">💳</span>
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -107,7 +106,7 @@ export default function LettreCreditPage() {
{/* How it works */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Fonctionnement Simplifié</h3>
<h3 className="font-semibold text-blue-900 mb-3">🔄 Fonctionnement Simplifié</h3>
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
{[
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
@ -130,7 +129,7 @@ export default function LettreCreditPage() {
{/* Parties */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Users className="w-5 h-5" /> Parties Impliquées</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -150,7 +149,7 @@ export default function LettreCreditPage() {
{/* Types */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Types de Lettres de Crédit</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{typesLC.map((lc) => (
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
@ -173,7 +172,7 @@ export default function LettreCreditPage() {
{/* Documents */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Typiquement Requis</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -193,7 +192,7 @@ export default function LettreCreditPage() {
{/* Key Dates */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5" /> Dates Clés à Surveiller</h3>
<h3 className="font-semibold text-gray-900 mb-3">📅 Dates Clés à Surveiller</h3>
<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">Date d&apos;expédition</h4>
@ -220,7 +219,7 @@ export default function LettreCreditPage() {
{/* Costs */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Coûts Typiques</h3>
<h3 className="font-semibold text-gray-900 mb-3">💰 Coûts Typiques</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>
@ -250,7 +249,7 @@ export default function LettreCreditPage() {
{/* Common Errors */}
<Card className="mt-8 bg-red-50 border-red-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Erreurs Fréquentes (Réserves)</h3>
<h3 className="font-semibold text-red-900 mb-3"> Erreurs Fréquentes (Réserves)</h3>
<p className="text-red-800 mb-3">
Ces erreurs entraînent des &quot;réserves&quot; de la banque et peuvent bloquer le paiement :
</p>
@ -265,7 +264,7 @@ export default function LettreCreditPage() {
{/* UCP 600 */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><ScrollText className="w-5 h-5" /> Règles UCP 600</h3>
<h3 className="font-semibold text-gray-900 mb-3">📜 Règles UCP 600</h3>
<p className="text-gray-600">
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
régissent les lettres de crédit documentaires depuis 2007. Points clés :
@ -282,7 +281,7 @@ export default function LettreCreditPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
<li>Demander des modifications AVANT expédition si nécessaire</li>

View File

@ -6,112 +6,89 @@
import Link from 'next/link';
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';
interface WikiTopic {
title: string;
description: string;
icon: LucideIcon;
href: string;
tags: string[];
}
const wikiTopics: WikiTopic[] = [
const wikiTopics = [
{
title: 'Incoterms 2020',
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
icon: ScrollText,
icon: '📜',
href: '/dashboard/wiki/incoterms',
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
},
{
title: 'Documents de Transport',
description: 'Les documents essentiels pour le transport maritime',
icon: ClipboardList,
icon: '📋',
href: '/dashboard/wiki/documents-transport',
tags: ['B/L', 'Sea Waybill', 'Manifest'],
},
{
title: 'Conteneurs et Types de Cargo',
description: 'Guide complet des types de conteneurs maritimes',
icon: Package,
icon: '📦',
href: '/dashboard/wiki/conteneurs',
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
},
{
title: 'LCL vs FCL',
description: 'Différences entre groupage et conteneur complet',
icon: Scale,
icon: '⚖️',
href: '/dashboard/wiki/lcl-vs-fcl',
tags: ['Groupage', 'Complet', 'Coûts'],
},
{
title: 'Procédures Douanières',
description: 'Guide des formalités douanières import/export',
icon: ShieldCheck,
icon: '🛃',
href: '/dashboard/wiki/douanes',
tags: ['Déclaration', 'Tarifs', 'Régimes'],
},
{
title: 'Assurance Maritime',
description: 'Protection des marchandises en transit',
icon: Shield,
icon: '🛡️',
href: '/dashboard/wiki/assurance',
tags: ['ICC A', 'ICC B', 'ICC C'],
},
{
title: 'Calcul du Fret Maritime',
description: 'Comment sont calculés les coûts de transport',
icon: Calculator,
icon: '🧮',
href: '/dashboard/wiki/calcul-fret',
tags: ['CBM', 'THC', 'BAF', 'CAF'],
},
{
title: 'Ports et Routes Maritimes',
description: 'Les principales routes commerciales mondiales',
icon: Globe,
icon: '🌍',
href: '/dashboard/wiki/ports-routes',
tags: ['Hub', 'Détroits', 'Canaux'],
},
{
title: 'VGM (Verified Gross Mass)',
description: 'Obligation de pesée des conteneurs (SOLAS)',
icon: Anchor,
icon: '⚓',
href: '/dashboard/wiki/vgm',
tags: ['SOLAS', 'Pesée', 'Certification'],
},
{
title: 'Marchandises Dangereuses (IMDG)',
description: 'Transport de matières dangereuses par mer',
icon: AlertTriangle,
icon: '⚠️',
href: '/dashboard/wiki/imdg',
tags: ['Classes', 'Étiquetage', 'Sécurité'],
},
{
title: 'Lettre de Crédit (L/C)',
description: 'Instrument de paiement international sécurisé',
icon: CreditCard,
icon: '💳',
href: '/dashboard/wiki/lettre-credit',
tags: ['Banque', 'Paiement', 'Sécurité'],
},
{
title: 'Transit Time et Délais',
description: 'Comprendre les délais en transport maritime',
icon: Timer,
icon: '⏱️',
href: '/dashboard/wiki/transit-time',
tags: ['Cut-off', 'Free time', 'Demurrage'],
},
@ -130,16 +107,12 @@ export default function WikiPage() {
{/* 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 (
{wikiTopics.map((topic) => (
<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>
<span className="text-4xl">{topic.icon}</span>
</div>
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
{topic.title}
@ -162,8 +135,7 @@ export default function WikiPage() {
</CardContent>
</Card>
</Link>
);
})}
))}
</div>
{/* Footer info */}

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Globe, BarChart3, Ship, Trophy, RefreshCw, Lightbulb, Anchor } from 'lucide-react';
const majorRoutes = [
{
@ -114,7 +113,7 @@ export default function PortsRoutesPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Globe className="w-10 h-10 text-blue-600" />
<span className="text-4xl">🌍</span>
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -126,7 +125,7 @@ export default function PortsRoutesPage() {
{/* Key Stats */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Chiffres Clés du Maritime Mondial</h3>
<h3 className="font-semibold text-blue-900 mb-3">📊 Chiffres Clés du Maritime Mondial</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-3xl font-bold text-blue-700">80%</p>
@ -150,7 +149,7 @@ export default function PortsRoutesPage() {
{/* Major Routes */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Routes Commerciales Majeures</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳 Routes Commerciales Majeures</h2>
<div className="space-y-4">
{majorRoutes.map((route) => (
<Card key={route.name} className="bg-white">
@ -185,7 +184,7 @@ export default function PortsRoutesPage() {
{/* Strategic Passages */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Anchor className="w-5 h-5" /> Passages Stratégiques</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4"> Passages Stratégiques</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{strategicPassages.map((passage) => (
<Card key={passage.name} className="bg-white">
@ -223,7 +222,7 @@ export default function PortsRoutesPage() {
{/* Top Ports */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Trophy className="w-5 h-5" /> Top 10 Ports Mondiaux (TEU)</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -262,7 +261,7 @@ export default function PortsRoutesPage() {
{/* Hub Ports Info */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Ports Hub vs Ports Régionaux</h3>
<h3 className="font-semibold text-gray-900 mb-3">🔄 Ports Hub vs Ports Régionaux</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
@ -287,7 +286,7 @@ export default function PortsRoutesPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { BookOpen, Calendar, Ship, DollarSign, RefreshCw, Lightbulb, Clock, AlertTriangle } from 'lucide-react';
const etapesTimeline = [
{
@ -133,7 +132,7 @@ export default function TransitTimePage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Clock className="w-10 h-10 text-blue-600" />
<span className="text-4xl"></span>
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -146,7 +145,7 @@ export default function TransitTimePage() {
{/* Key Terms */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BookOpen className="w-5 h-5" /> Termes Clés</h3>
<h3 className="font-semibold text-blue-900 mb-3">📖 Termes Clés</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<h4 className="font-medium text-blue-800">ETD</h4>
@ -170,7 +169,7 @@ export default function TransitTimePage() {
{/* Timeline */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Calendar className="w-5 h-5" /> Timeline d&apos;une Expédition FCL</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d&apos;une Expédition FCL</h2>
<div className="space-y-3">
{etapesTimeline.map((item, index) => (
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
@ -196,7 +195,7 @@ export default function TransitTimePage() {
{/* Transit Times */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Transit Times Indicatifs</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -229,7 +228,7 @@ export default function TransitTimePage() {
{/* Free Time */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Clock className="w-5 h-5" /> Free Time (Jours Gratuits)</h3>
<h3 className="font-semibold text-gray-900 mb-3"> Free Time (Jours Gratuits)</h3>
<p className="text-gray-600 mb-4">
Période pendant laquelle le conteneur peut rester au terminal ou chez l&apos;importateur
sans frais supplémentaires.
@ -258,7 +257,7 @@ export default function TransitTimePage() {
{/* Late Fees */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Frais de Retard</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{fraisRetard.map((frais) => (
<Card key={frais.nom} className="bg-white border-red-200">
@ -284,7 +283,7 @@ export default function TransitTimePage() {
{/* Factors affecting transit */}
<Card className="mt-8 bg-orange-50 border-orange-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Facteurs Impactant les Délais</h3>
<h3 className="font-semibold text-orange-900 mb-3"> Facteurs Impactant les Délais</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
@ -313,7 +312,7 @@ export default function TransitTimePage() {
{/* Roll-over */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Roll-over (Report)</h3>
<h3 className="font-semibold text-gray-900 mb-3">🔄 Roll-over (Report)</h3>
<p className="text-gray-600 mb-3">
Situation un conteneur n&apos;est pas chargé sur le navire prévu et est reporté
sur le prochain départ.
@ -337,7 +336,7 @@ export default function TransitTimePage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser les Délais</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser les Délais</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Réserver tôt, surtout en haute saison (2-3 semaines d&apos;avance)</li>
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>

View File

@ -6,7 +6,6 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Shield, Construction, Truck, ClipboardList, Microscope, User, Ruler, Lightbulb, Anchor, Scale, AlertTriangle } from 'lucide-react';
const methodesPesee = [
{
@ -70,7 +69,7 @@ export default function VGMPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<Anchor className="w-10 h-10 text-blue-600" />
<span className="text-4xl"></span>
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -86,19 +85,19 @@ export default function VGMPage() {
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
<div>
<h4 className="font-medium flex items-center gap-1"><Shield className="w-4 h-4" /> Sécurité</h4>
<h4 className="font-medium">🛡 Sécurité</h4>
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
</div>
<div>
<h4 className="font-medium flex items-center gap-1"><Scale className="w-4 h-4" /> Stabilité du navire</h4>
<h4 className="font-medium"> Stabilité du navire</h4>
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
</div>
<div>
<h4 className="font-medium flex items-center gap-1"><Construction className="w-4 h-4" /> Équipements portuaires</h4>
<h4 className="font-medium">🏗 Équipements portuaires</h4>
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
</div>
<div>
<h4 className="font-medium flex items-center gap-1"><Truck className="w-4 h-4" /> Transport terrestre</h4>
<h4 className="font-medium">🚛 Transport terrestre</h4>
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
</div>
</div>
@ -107,7 +106,7 @@ export default function VGMPage() {
{/* VGM Components */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Composants du VGM</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
@ -132,7 +131,7 @@ export default function VGMPage() {
{/* Methods */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Microscope className="w-5 h-5" /> Méthodes de Détermination</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
<div className="space-y-4">
{methodesPesee.map((method) => (
<Card key={method.method} className="bg-white">
@ -187,7 +186,7 @@ export default function VGMPage() {
{/* Responsibility */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><User className="w-5 h-5" /> Responsabilités</h3>
<h3 className="font-semibold text-gray-900 mb-3">👤 Responsabilités</h3>
<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">Expéditeur (Shipper)</h4>
@ -214,7 +213,7 @@ export default function VGMPage() {
{/* Tolerances */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Tolérances</h3>
<h3 className="font-semibold text-gray-900 mb-3">📏 Tolérances</h3>
<div className="bg-white p-4 rounded-lg border">
<p className="text-gray-600 mb-3">
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
@ -235,7 +234,7 @@ export default function VGMPage() {
{/* Sanctions */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Sanctions par Région</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4"> Sanctions par Région</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -253,7 +252,7 @@ export default function VGMPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Bonnes Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3">💡 Bonnes Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
<li>Utiliser des balances étalonnées et certifiées</li>

View File

@ -13,66 +13,6 @@ import Link from 'next/link';
import Image from 'next/image';
import { useAuth } from '@/lib/context/auth-context';
interface FieldErrors {
email?: string;
password?: string;
}
// Map backend error messages to French user-friendly messages
function getErrorMessage(error: any): { message: string; field?: 'email' | 'password' | 'general' } {
const errorMessage = error?.message || error?.response?.message || '';
// Network or server errors
if (error?.name === 'TypeError' || errorMessage.includes('fetch')) {
return {
message: 'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
field: 'general'
};
}
// Backend error messages
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('Identifiants')) {
return {
message: 'Email ou mot de passe incorrect',
field: 'general'
};
}
if (errorMessage.includes('inactive') || errorMessage.includes('désactivé')) {
return {
message: 'Votre compte a été désactivé. Contactez le support pour plus d\'informations.',
field: 'general'
};
}
if (errorMessage.includes('not found') || errorMessage.includes('introuvable')) {
return {
message: 'Aucun compte trouvé avec cet email',
field: 'email'
};
}
if (errorMessage.includes('password') || errorMessage.includes('mot de passe')) {
return {
message: 'Mot de passe incorrect',
field: 'password'
};
}
if (errorMessage.includes('Too many') || errorMessage.includes('rate limit')) {
return {
message: 'Trop de tentatives de connexion. Veuillez réessayer dans quelques minutes.',
field: 'general'
};
}
// Default error
return {
message: errorMessage || 'Une erreur est survenue. Veuillez réessayer.',
field: 'general'
};
}
export default function LoginPage() {
const { login } = useAuth();
const [email, setEmail] = useState('');
@ -80,64 +20,17 @@ export default function LoginPage() {
const [rememberMe, setRememberMe] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
// Validate form fields
const validateForm = (): boolean => {
const errors: FieldErrors = {};
// Email validation
if (!email.trim()) {
errors.email = 'L\'adresse email est requise';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'L\'adresse email n\'est pas valide';
}
// Password validation
if (!password) {
errors.password = 'Le mot de passe est requis';
} else if (password.length < 6) {
errors.password = 'Le mot de passe doit contenir au moins 6 caractères';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle input changes - keep errors visible until successful login
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setFieldErrors({});
// Validate form before submission
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
await login(email, password);
// Navigation is handled by the login function in auth context
} catch (err: any) {
const { message, field } = getErrorMessage(err);
if (field === 'email') {
setFieldErrors({ email: message });
} else if (field === 'password') {
setFieldErrors({ password: message });
} else {
setError(message);
}
setError(err.message || 'Identifiants incorrects');
} finally {
setIsLoading(false);
}
@ -172,20 +65,7 @@ export default function LoginPage() {
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg
className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-body-sm text-red-800">{error}</p>
</div>
)}
@ -194,74 +74,38 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email */}
<div>
<label htmlFor="email" className={`label ${fieldErrors.email ? 'text-red-600' : ''}`}>
<label htmlFor="email" className="label">
Adresse email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={handleEmailChange}
className={`input w-full ${
fieldErrors.email
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
: ''
}`}
onChange={e => setEmail(e.target.value)}
className="input w-full"
placeholder="votre.email@entreprise.com"
autoComplete="email"
disabled={isLoading}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
/>
{fieldErrors.email && (
<p id="email-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{fieldErrors.email}
</p>
)}
</div>
{/* Password */}
<div>
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}>
<label htmlFor="password" className="label">
Mot de passe
</label>
<input
id="password"
type="password"
required
value={password}
onChange={handlePasswordChange}
className={`input w-full ${
fieldErrors.password
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
: ''
}`}
onChange={e => setPassword(e.target.value)}
className="input w-full"
placeholder="••••••••••"
autoComplete="current-password"
disabled={isLoading}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
/>
{fieldErrors.password && (
<p id="password-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{fieldErrors.password}
</p>
)}
</div>
{/* Remember Me & Forgot Password */}

View File

@ -1,221 +0,0 @@
'use client';
import { useRef } from 'react';
import Link from 'next/link';
import { motion, useInView } from 'framer-motion';
import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
export default function NotFound() {
const heroRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true });
return (
<div className="min-h-screen bg-white flex flex-col">
<LandingHeader transparentOnTop={true} />
{/* Hero Section */}
<section
ref={heroRef}
className="relative flex-1 flex items-center justify-center overflow-hidden"
>
{/* Background */}
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" />
</div>
</div>
{/* Content */}
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40">
{/* Badge */}
<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-10 border border-white/20"
>
<Anchor className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium font-heading">
Erreur 404
</span>
</motion.div>
{/* Animated Ship + Waves illustration */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={isHeroInView ? { opacity: 1, scale: 1 } : {}}
transition={{ duration: 0.8, delay: 0.3 }}
className="relative mb-8 flex justify-center"
>
<div className="relative w-64 h-40 lg:w-80 lg:h-48">
{/* Ship */}
<svg
viewBox="0 0 200 120"
className="absolute inset-0 w-full h-full animate-float"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Hull */}
<path
d="M40 75 L55 95 L145 95 L160 75 Z"
fill="#34CCCD"
opacity="0.9"
/>
{/* Deck */}
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
{/* Bridge */}
<rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" />
{/* Window */}
<rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
<rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
{/* Smokestack */}
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
{/* Mast */}
<line x1="102" y1="10" x2="102" y2="22" stroke="white" strokeWidth="1.5" opacity="0.7" />
{/* Flag */}
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
{/* Containers on deck */}
<rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" />
<rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" />
<rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" />
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" />
</svg>
{/* Waves layer 1 */}
<svg
viewBox="0 0 400 30"
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z"
fill="#34CCCD"
opacity="0.3"
/>
</svg>
{/* Waves layer 2 */}
<svg
viewBox="0 0 400 30"
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z"
fill="#34CCCD"
opacity="0.15"
/>
</svg>
</div>
</motion.div>
{/* 404 */}
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.4 }}
className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight"
>
404
</motion.h1>
{/* Subtitle */}
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.5 }}
className="text-3xl lg:text-5xl font-bold mb-6 font-heading"
>
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
Page introuvable
</span>
</motion.h2>
{/* Description */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.6 }}
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
>
Ce navire a pris le large... La page que vous cherchez
n&apos;existe pas ou a é déplacée.
</motion.p>
{/* CTA Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.7 }}
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
>
<Link
href="/"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
>
<Home className="w-5 h-5" />
<span>Retour à l&apos;accueil</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/dashboard"
className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
>
<LayoutDashboard className="w-5 h-5" />
<span>Tableau de bord</span>
</Link>
</motion.div>
</div>
{/* Bottom wave */}
<div className="absolute bottom-0 left-0 right-0">
<svg
className="w-full h-16 lg:h-24"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
fill="white"
/>
</svg>
</div>
</section>
<LandingFooter />
<style jsx global>{`
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
25% { transform: translateY(-6px) rotate(1deg); }
75% { transform: translateY(4px) rotate(-1deg); }
}
@keyframes wave {
0% { transform: translateX(-50%) translateX(0); }
100% { transform: translateX(-50%) translateX(-50px); }
}
@keyframes wave-slow {
0% { transform: translateX(-50%) translateX(0); }
100% { transform: translateX(-50%) translateX(50px); }
}
.animate-float {
animation: float 4s ease-in-out infinite;
}
.animate-wave {
animation: wave 3s ease-in-out infinite alternate;
}
.animate-wave-slow {
animation: wave-slow 4s ease-in-out infinite alternate;
}
`}</style>
</div>
);
}

View File

@ -52,7 +52,7 @@ export default function LandingPage() {
const features = [
{
icon: BarChart3,
title: 'Tableau de bord',
title: 'Dashboard Analytics',
description:
'Suivez tous vos KPIs en temps réel : bookings, volumes, revenus et alertes personnalisées.',
color: 'from-blue-500 to-cyan-500',
@ -60,7 +60,7 @@ export default function LandingPage() {
},
{
icon: Package,
title: 'Gestion des Réservations',
title: 'Gestion des Bookings',
description: 'Créez, gérez et suivez vos réservations maritimes LCL/FCL avec un historique complet.',
color: 'from-purple-500 to-pink-500',
link: '/dashboard/bookings',
@ -74,7 +74,7 @@ export default function LandingPage() {
},
{
icon: Search,
title: 'Suivi des expéditions',
title: 'Track & Trace',
description:
'Suivez vos conteneurs en temps réel auprès de 10+ transporteurs majeurs (Maersk, MSC, CMA CGM...).',
color: 'from-green-500 to-emerald-500',
@ -101,13 +101,13 @@ export default function LandingPage() {
const tools = [
{
icon: LayoutDashboard,
title: 'Tableau de bord',
title: 'Dashboard',
description: 'Vue d\'ensemble de votre activité maritime',
link: '/dashboard',
},
{
icon: Package,
title: 'Mes Réservations',
title: 'Mes Bookings',
description: 'Gérez toutes vos réservations en un seul endroit',
link: '/dashboard/bookings',
},
@ -119,7 +119,7 @@ export default function LandingPage() {
},
{
icon: Search,
title: 'Suivi des expéditions',
title: 'Track & Trace',
description: 'Suivez vos conteneurs en temps réel',
link: '/dashboard/track-trace',
},
@ -158,7 +158,7 @@ export default function LandingPage() {
{ text: 'Support par email', included: true },
{ text: 'Gestion des documents', included: false },
{ text: 'Notifications temps réel', included: false },
{ text: 'Accès API', included: false },
{ text: 'API access', included: false },
],
cta: 'Commencer gratuitement',
highlighted: false,
@ -176,7 +176,7 @@ export default function LandingPage() {
{ text: 'Support prioritaire', included: true },
{ text: 'Gestion des documents', included: true },
{ text: 'Notifications temps réel', included: true },
{ text: 'Accès API', included: false },
{ text: 'API access', included: false },
],
cta: 'Essai gratuit 14 jours',
highlighted: true,
@ -187,10 +187,10 @@ export default function LandingPage() {
period: '',
description: 'Pour les grandes entreprises',
features: [
{ text: 'Tout Professionnel +', included: true },
{ text: 'Accès API complet', included: true },
{ text: 'Tout Professional +', included: true },
{ text: 'API access complet', included: true },
{ text: 'Intégrations personnalisées', included: true },
{ text: 'Responsable de compte dédié', included: true },
{ text: 'Account manager dédié', included: true },
{ text: 'SLA garanti 99.9%', included: true },
{ text: 'Formation sur site', included: true },
{ text: 'Multi-organisations', included: true },
@ -323,7 +323,7 @@ export default function LandingPage() {
href="/dashboard"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
>
<span>Accéder au tableau de bord</span>
<span>Accéder au Dashboard</span>
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
) : (
@ -709,13 +709,13 @@ export default function LandingPage() {
{
step: '03',
title: 'Réservez',
description: 'Confirmez votre réservation en un clic',
description: 'Confirmez votre booking en un clic',
icon: CheckCircle2,
},
{
step: '04',
title: 'Suivez',
description: 'Suivez votre envoi en temps réel',
description: 'Trackez votre envoi en temps réel',
icon: Container,
},
].map((step, index) => {
@ -833,7 +833,7 @@ export default function LandingPage() {
href="/dashboard"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
>
<span>Accéder au tableau de bord</span>
<span>Accéder au Dashboard</span>
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
) : (

View File

@ -1,279 +1,265 @@
/**
* Cookie Consent Banner
* GDPR Compliant - French version
* GDPR Compliant
*/
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { motion, AnimatePresence } from 'framer-motion';
import { Cookie, X, Settings, Check, Shield } from 'lucide-react';
import { useCookieConsent } from '@/lib/context/cookie-context';
import type { CookiePreferences } from '@/lib/api/gdpr';
interface CookiePreferences {
essential: boolean; // Always true (required for functionality)
functional: boolean;
analytics: boolean;
marketing: boolean;
}
export default function CookieConsent() {
const {
preferences,
showBanner,
showSettings,
isLoading,
setShowBanner,
setShowSettings,
acceptAll,
acceptEssentialOnly,
savePreferences,
openPreferences,
} = useCookieConsent();
const [showBanner, setShowBanner] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>({
essential: true,
functional: true,
analytics: false,
marketing: false,
});
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>(preferences);
useEffect(() => {
// Check if user has already made a choice
const consent = localStorage.getItem('cookieConsent');
if (!consent) {
setShowBanner(true);
} else {
const savedPreferences = JSON.parse(consent);
setPreferences(savedPreferences);
}
}, []);
// Sync local prefs when context changes
React.useEffect(() => {
setLocalPrefs(preferences);
}, [preferences]);
const handleSaveCustom = async () => {
await savePreferences(localPrefs);
const acceptAll = () => {
const allAccepted: CookiePreferences = {
essential: true,
functional: true,
analytics: true,
marketing: true,
};
savePreferences(allAccepted);
};
// Don't render anything while loading
if (isLoading) {
const acceptEssentialOnly = () => {
const essentialOnly: CookiePreferences = {
essential: true,
functional: false,
analytics: false,
marketing: false,
};
savePreferences(essentialOnly);
};
const saveCustomPreferences = () => {
savePreferences(preferences);
};
const savePreferences = (prefs: CookiePreferences) => {
localStorage.setItem('cookieConsent', JSON.stringify(prefs));
localStorage.setItem('cookieConsentDate', new Date().toISOString());
setShowBanner(false);
setShowSettings(false);
// Apply preferences
applyPreferences(prefs);
};
const applyPreferences = (prefs: CookiePreferences) => {
// Enable/disable analytics tracking
if (prefs.analytics) {
// Enable Google Analytics, Sentry, etc.
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
analytics_storage: 'granted',
});
}
} else {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
analytics_storage: 'denied',
});
}
}
// Enable/disable marketing tracking
if (prefs.marketing) {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
ad_storage: 'granted',
});
}
} else {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
ad_storage: 'denied',
});
}
}
};
if (!showBanner) {
return null;
}
return (
<>
{/* Floating Cookie Button (shown when banner is closed) */}
<AnimatePresence>
{!showBanner && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
onClick={openPreferences}
className="fixed bottom-4 left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors"
aria-label="Ouvrir les paramètres de cookies"
>
<Cookie className="w-5 h-5" />
</motion.button>
)}
</AnimatePresence>
{/* Cookie Banner */}
<AnimatePresence>
{showBanner && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-2xl"
>
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-gray-200 shadow-2xl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<AnimatePresence mode="wait">
{!showSettings ? (
// Simple banner
<motion.div
key="simple"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4"
>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Cookie className="w-5 h-5 text-brand-navy" />
<h3 className="text-lg font-semibold text-brand-navy">
Nous utilisons des cookies
</h3>
</div>
<p className="text-sm text-gray-600 max-w-2xl">
Nous utilisons des cookies pour améliorer votre expérience, analyser le
trafic du site et personnaliser le contenu. En cliquant sur « Tout
accepter », vous consentez à notre utilisation des cookies.{' '}
<Link
href="/cookies"
className="text-brand-turquoise hover:text-brand-turquoise/80 underline"
>
En savoir plus
<h3 className="text-lg font-semibold text-gray-900 mb-2">🍪 We use cookies</h3>
<p className="text-sm text-gray-600">
We use cookies to improve your experience, analyze site traffic, and personalize
content. By clicking "Accept All", you consent to our use of cookies.{' '}
<Link href="/privacy" className="text-blue-600 hover:text-blue-800 underline">
Learn more
</Link>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => setShowSettings(true)}
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Settings className="w-4 h-4" />
Personnaliser
Customize
</button>
<button
onClick={acceptEssentialOnly}
className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Essentiel uniquement
Essential Only
</button>
<button
onClick={acceptAll}
className="flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Check className="w-4 h-4" />
Tout accepter
Accept All
</button>
</div>
</motion.div>
</div>
) : (
// Detailed settings
<motion.div
key="settings"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-brand-navy" />
<h3 className="text-lg font-semibold text-brand-navy">
Préférences de cookies
</h3>
</div>
<h3 className="text-lg font-semibold text-gray-900">Cookie Preferences</h3>
<button
onClick={() => setShowSettings(false)}
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Fermer les paramètres"
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
<div className="space-y-4 mb-6">
{/* Essential Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900">
Cookies essentiels
</h4>
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
Toujours actif
<div className="flex items-center">
<h4 className="text-sm font-semibold text-gray-900">Essential Cookies</h4>
<span className="ml-2 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded">
Always Active
</span>
</div>
<p className="mt-1 text-sm text-gray-600">
Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés.
Required for the website to function. Cannot be disabled.
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={true}
disabled
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded"
/>
</div>
</div>
{/* Functional Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">
Cookies fonctionnels
</h4>
<h4 className="text-sm font-semibold text-gray-900">Functional Cookies</h4>
<p className="mt-1 text-sm text-gray-600">
Permettent de mémoriser vos préférences et paramètres (langue, gion).
Remember your preferences and settings (e.g., language, region).
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={localPrefs.functional}
onChange={e =>
setLocalPrefs({ ...localPrefs, functional: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
checked={preferences.functional}
onChange={e => setPreferences({ ...preferences, functional: e.target.checked })}
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</div>
</div>
{/* Analytics Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">
Cookies analytiques
</h4>
<h4 className="text-sm font-semibold text-gray-900">Analytics Cookies</h4>
<p className="mt-1 text-sm text-gray-600">
Nous aident à comprendre comment les visiteurs interagissent avec notre
site (Google Analytics, Sentry).
Help us understand how visitors interact with our website (Google Analytics,
Sentry).
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={localPrefs.analytics}
onChange={e =>
setLocalPrefs({ ...localPrefs, analytics: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
checked={preferences.analytics}
onChange={e => setPreferences({ ...preferences, analytics: e.target.checked })}
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</div>
</div>
{/* Marketing Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">
Cookies marketing
</h4>
<h4 className="text-sm font-semibold text-gray-900">Marketing Cookies</h4>
<p className="mt-1 text-sm text-gray-600">
Utilisés pour afficher des publicités personnalisées et mesurer
l'efficacité des campagnes.
Used to deliver personalized ads and measure campaign effectiveness.
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={localPrefs.marketing}
onChange={e =>
setLocalPrefs({ ...localPrefs, marketing: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
checked={preferences.marketing}
onChange={e => setPreferences({ ...preferences, marketing: e.target.checked })}
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleSaveCustom}
className="flex-1 flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
onClick={saveCustomPreferences}
className="flex-1 px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Check className="w-4 h-4" />
Enregistrer mes préférences
Save Preferences
</button>
<button
onClick={acceptAll}
className="flex-1 px-6 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="flex-1 px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Tout accepter
Accept All
</button>
</div>
<p className="mt-4 text-xs text-gray-500 text-center">
Vous pouvez modifier vos préférences à tout moment dans les paramètres de
votre compte ou en cliquant sur l'icône cookie en bas à gauche.{' '}
<Link href="/cookies" className="text-brand-turquoise hover:underline">
Politique de cookies
</Link>
You can change your preferences at any time in your account settings or by clicking
the cookie icon in the footer.
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</>
);
}

View File

@ -1,244 +0,0 @@
/**
* Export Button Component
*
* Reusable component for exporting data to CSV or Excel
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
interface ExportButtonProps<T> {
data: T[];
filename: string;
columns: {
key: keyof T | string;
label: string;
format?: (value: any, row: T) => string;
}[];
disabled?: boolean;
}
export default function ExportButton<T extends Record<string, any>>({
data,
filename,
columns,
disabled = false,
}: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? 'Oui' : 'Non';
if (value instanceof Date) return value.toLocaleDateString('fr-FR');
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const getNestedValue = (obj: T, path: string): any => {
return path.split('.').reduce((acc, part) => acc?.[part], obj as any);
};
const generateCSV = (): string => {
// Headers
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
// Rows
const rows = data.map(row => {
return columns
.map(col => {
const value = getNestedValue(row, col.key as string);
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
// Escape quotes and wrap in quotes
return `"${formattedValue.replace(/"/g, '""')}"`;
})
.join(';');
});
return [headers, ...rows].join('\n');
};
const downloadFile = (content: string, mimeType: string, extension: string) => {
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
const blob = new Blob([BOM + content], { type: `${mimeType};charset=utf-8` });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}_${new Date().toISOString().split('T')[0]}.${extension}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleExportCSV = async () => {
setIsExporting(true);
try {
const csv = generateCSV();
downloadFile(csv, 'text/csv', 'csv');
} catch (error) {
console.error('Export CSV error:', error);
alert('Erreur lors de l\'export CSV');
} finally {
setIsExporting(false);
setIsOpen(false);
}
};
const handleExportExcel = async () => {
setIsExporting(true);
try {
// Generate Excel-compatible XML (SpreadsheetML)
const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles>
<Style ss:ID="Header">
<Font ss:Bold="1" ss:Color="#FFFFFF"/>
<Interior ss:Color="#3B82F6" ss:Pattern="Solid"/>
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
</Style>
<Style ss:ID="Default">
<Alignment ss:Vertical="Center"/>
</Style>
</Styles>
<Worksheet ss:Name="Export">
<Table>`;
// Column widths
const columnWidths = columns.map(() => '<Column ss:AutoFitWidth="1" ss:Width="120"/>').join('');
// Header row
const headerRow = `<Row ss:StyleID="Header">
${columns.map(col => `<Cell><Data ss:Type="String">${escapeXml(col.label)}</Data></Cell>`).join('')}
</Row>`;
// Data rows
const dataRows = data
.map(row => {
const cells = columns
.map(col => {
const value = getNestedValue(row, col.key as string);
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
const type = typeof value === 'number' ? 'Number' : 'String';
return `<Cell ss:StyleID="Default"><Data ss:Type="${type}">${escapeXml(formattedValue)}</Data></Cell>`;
})
.join('');
return `<Row>${cells}</Row>`;
})
.join('');
const xmlFooter = `</Table>
</Worksheet>
</Workbook>`;
const xmlContent = xmlHeader + columnWidths + headerRow + dataRows + xmlFooter;
downloadFile(xmlContent, 'application/vnd.ms-excel', 'xls');
} catch (error) {
console.error('Export Excel error:', error);
alert('Erreur lors de l\'export Excel');
} finally {
setIsExporting(false);
setIsOpen(false);
}
};
const escapeXml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={disabled || data.length === 0 || isExporting}
className="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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isExporting ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-600"
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>
Export en cours...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Exporter
<ChevronDown className="ml-2 h-4 w-4" />
</>
)}
</button>
{/* Dropdown Menu */}
{isOpen && !isExporting && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="py-1">
<button
onClick={handleExportCSV}
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileText className="mr-3 h-4 w-4 text-green-600" />
<div className="text-left">
<div className="font-medium">Export CSV</div>
<div className="text-xs text-gray-500">Compatible Excel, Google Sheets</div>
</div>
</button>
<button
onClick={handleExportExcel}
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileSpreadsheet className="mr-3 h-4 w-4 text-blue-600" />
<div className="text-left">
<div className="font-medium">Export Excel</div>
<div className="text-xs text-gray-500">Format .xls natif</div>
</div>
</button>
</div>
<div className="border-t border-gray-100 px-4 py-2">
<p className="text-xs text-gray-500">
{data.length} ligne{data.length > 1 ? 's' : ''} à exporter
</p>
</div>
</div>
)}
</div>
);
}

View File

@ -11,18 +11,6 @@ import { useState, useRef, useEffect } from 'react';
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import NotificationPanel from './NotificationPanel';
import {
CheckCircle,
RefreshCw,
XCircle,
DollarSign,
Ship,
Settings,
AlertTriangle,
Bell,
Megaphone,
type LucideIcon,
} from 'lucide-react';
export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false);
@ -95,17 +83,17 @@ export default function NotificationDropdown() {
return colors[priority as keyof typeof colors] || colors.low;
};
const getNotificationIcon = (type: string): LucideIcon => {
const icons: Record<string, LucideIcon> = {
BOOKING_CONFIRMED: CheckCircle,
BOOKING_UPDATED: RefreshCw,
BOOKING_CANCELLED: XCircle,
RATE_ALERT: DollarSign,
CARRIER_UPDATE: Ship,
SYSTEM: Settings,
WARNING: AlertTriangle,
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
BOOKING_CONFIRMED: '✅',
BOOKING_UPDATED: '🔄',
BOOKING_CANCELLED: '❌',
RATE_ALERT: '💰',
CARRIER_UPDATE: '🚢',
SYSTEM: '⚙️',
WARNING: '⚠️',
};
return icons[type] || Megaphone;
return icons[type] || '📢';
};
const formatTime = (dateString: string) => {
@ -116,10 +104,10 @@ export default function NotificationDropdown() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
@ -158,7 +146,7 @@ export default function NotificationDropdown() {
disabled={markAllAsReadMutation.isPending}
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
Tout marquer comme lu
Mark all as read
</button>
)}
</div>
@ -166,11 +154,11 @@ export default function NotificationDropdown() {
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-sm text-gray-500">Chargement des notifications...</div>
<div className="p-4 text-center text-sm text-gray-500">Loading notifications...</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<Bell className="h-10 w-10 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">Aucune nouvelle notification</p>
<div className="text-4xl mb-2">🔔</div>
<p className="text-sm text-gray-500">No new notifications</p>
</div>
) : (
<div className="divide-y">
@ -184,8 +172,8 @@ export default function NotificationDropdown() {
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{(() => { const Icon = getNotificationIcon(notification.type); return <Icon className="h-5 w-5 text-gray-500" />; })()}
<div className="flex-shrink-0 text-2xl">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
@ -222,7 +210,7 @@ export default function NotificationDropdown() {
}}
className="w-full text-center text-sm text-blue-600 hover:text-blue-800 font-medium py-2 hover:bg-blue-50 rounded transition-colors"
>
Voir toutes les notifications
View all notifications
</button>
</div>
</div>

View File

@ -16,7 +16,7 @@ import {
deleteNotification
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import { X, Trash2, CheckCheck, Filter, Bell, Package, RefreshCw, XCircle, CheckCircle, Mail, Timer, FileText, Megaphone, User, Building2 } from 'lucide-react';
import { X, Trash2, CheckCheck, Filter } from 'lucide-react';
interface NotificationPanelProps {
isOpen: boolean;
@ -83,7 +83,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Voulez-vous vraiment supprimer cette notification ?')) {
if (confirm('Are you sure you want to delete this notification?')) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -98,22 +98,22 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
};
const getNotificationIconComponent = (type: string) => {
const icons: Record<string, typeof Bell> = {
booking_created: Package,
booking_updated: RefreshCw,
booking_cancelled: XCircle,
booking_confirmed: CheckCircle,
csv_booking_accepted: CheckCircle,
csv_booking_rejected: XCircle,
csv_booking_request_sent: Mail,
rate_quote_expiring: Timer,
document_uploaded: FileText,
system_announcement: Megaphone,
user_invited: User,
organization_update: Building2,
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
booking_created: '📦',
booking_updated: '🔄',
booking_cancelled: '❌',
booking_confirmed: '✅',
csv_booking_accepted: '✅',
csv_booking_rejected: '❌',
csv_booking_request_sent: '📧',
rate_quote_expiring: '⏰',
document_uploaded: '📄',
system_announcement: '📢',
user_invited: '👤',
organization_update: '🏢',
};
return icons[type.toLowerCase()] || Bell;
return icons[type.toLowerCase()] || '🔔';
};
const formatTime = (dateString: string) => {
@ -124,13 +124,13 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
@ -158,7 +158,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
aria-label="Fermer le panneau"
aria-label="Close panel"
>
<X className="w-6 h-6" />
</button>
@ -182,7 +182,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
}`}
>
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
@ -194,7 +194,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
<span>Tout marquer comme lu</span>
<span>Mark all as read</span>
</button>
)}
</div>
@ -205,18 +205,18 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-sm text-gray-500">Chargement des notifications...</p>
<p className="text-sm text-gray-500">Loading notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Bell className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Aucune notification</h3>
<div className="text-6xl mb-4">🔔</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No notifications</h3>
<p className="text-sm text-gray-500">
{selectedFilter === 'unread'
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
? "You're all caught up!"
: 'No notifications to display'}
</p>
</div>
</div>
@ -232,8 +232,8 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0">
{(() => { const Icon = getNotificationIconComponent(notification.type); return <Icon className="h-6 w-6 text-gray-500" />; })()}
<div className="flex-shrink-0 text-3xl">
{getNotificationIcon(notification.type)}
</div>
{/* Content */}
@ -250,7 +250,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<button
onClick={(e) => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
title="Supprimer la notification"
title="Delete notification"
>
<Trash2 className="w-4 h-4 text-red-600" />
</button>
@ -287,7 +287,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
</div>
{notification.actionUrl && (
<span className="text-xs text-blue-600 font-medium group-hover:underline">
Voir les tails
View details
</span>
)}
</div>
@ -303,7 +303,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
<div className="text-sm text-gray-600">
Page {currentPage} sur {totalPages}
Page {currentPage} of {totalPages}
</div>
<div className="flex space-x-2">
<button
@ -311,14 +311,14 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
Previous
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
Next
</button>
</div>
</div>

View File

@ -1,7 +1,6 @@
"use client";
import { useEffect, useMemo } from "react";
import { MapContainer, TileLayer, Polyline, Marker, useMap } from "react-leaflet";
import { MapContainer, TileLayer, Polyline, Marker } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
@ -20,352 +19,22 @@ const DefaultIcon = L.icon({
});
L.Marker.prototype.options.icon = DefaultIcon;
// Maritime waypoints for major shipping routes
const WAYPOINTS = {
// Mediterranean / Suez route
gibraltar: { lat: 36.1, lng: -5.3 },
suezNorth: { lat: 31.2, lng: 32.3 },
suezSouth: { lat: 29.9, lng: 32.5 },
babElMandeb: { lat: 12.6, lng: 43.3 },
// Indian Ocean
sriLanka: { lat: 6.0, lng: 80.0 },
// Southeast Asia
malacca: { lat: 1.3, lng: 103.8 },
singapore: { lat: 1.2, lng: 103.8 },
// East Asia
hongKong: { lat: 22.3, lng: 114.2 },
taiwan: { lat: 23.5, lng: 121.0 },
// Atlantic
azores: { lat: 38.7, lng: -27.2 },
// Americas
panama: { lat: 9.0, lng: -79.5 },
// Cape route (alternative to Suez)
capeTown: { lat: -34.0, lng: 18.5 },
capeAgulhas: { lat: -34.8, lng: 20.0 },
};
type Region = 'northEurope' | 'medEurope' | 'eastAsia' | 'southeastAsia' | 'india' | 'middleEast' | 'eastAfrica' | 'westAfrica' | 'northAmerica' | 'southAmerica' | 'oceania' | 'unknown';
// Determine the region of a port based on coordinates
function getRegion(port: { lat: number; lng: number }): Region {
const { lat, lng } = port;
// North Europe (including UK, Scandinavia, North Sea, Baltic)
if (lat > 45 && lat < 70 && lng > -15 && lng < 30) return 'northEurope';
// Mediterranean Europe
if (lat > 30 && lat <= 45 && lng > -10 && lng < 40) return 'medEurope';
// East Asia (China, Japan, Korea)
if (lat > 20 && lat < 55 && lng > 100 && lng < 150) return 'eastAsia';
// Southeast Asia (Vietnam, Thailand, Malaysia, Indonesia, Philippines)
if (lat > -10 && lat <= 20 && lng > 95 && lng < 130) return 'southeastAsia';
// India / South Asia
if (lat > 5 && lat < 35 && lng > 65 && lng < 95) return 'india';
// Middle East (Persian Gulf, Red Sea)
if (lat > 10 && lat < 35 && lng > 30 && lng < 65) return 'middleEast';
// East Africa
if (lat > -35 && lat < 15 && lng > 25 && lng < 55) return 'eastAfrica';
// West Africa
if (lat > -35 && lat < 35 && lng > -25 && lng < 25) return 'westAfrica';
// North America (East Coast mainly)
if (lat > 10 && lat < 60 && lng > -130 && lng < -50) return 'northAmerica';
// South America
if (lat > -60 && lat <= 10 && lng > -90 && lng < -30) return 'southAmerica';
// Oceania (Australia, New Zealand)
if (lat > -50 && lat < 0 && lng > 110 && lng < 180) return 'oceania';
return 'unknown';
}
// Calculate maritime route waypoints between two ports
function calculateMaritimeRoute(
portA: { lat: number; lng: number },
portB: { lat: number; lng: number }
): Array<{ lat: number; lng: number }> {
const regionA = getRegion(portA);
const regionB = getRegion(portB);
const route: Array<{ lat: number; lng: number }> = [portA];
// Europe to East Asia via Suez
if (
(regionA === 'northEurope' || regionA === 'medEurope') &&
(regionB === 'eastAsia' || regionB === 'southeastAsia')
) {
if (regionA === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
route.push(WAYPOINTS.suezNorth);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.malacca);
if (regionB === 'eastAsia') {
route.push(WAYPOINTS.hongKong);
}
}
// East Asia to Europe via Suez (reverse)
else if (
(regionA === 'eastAsia' || regionA === 'southeastAsia') &&
(regionB === 'northEurope' || regionB === 'medEurope')
) {
if (regionA === 'eastAsia') {
route.push(WAYPOINTS.hongKong);
}
route.push(WAYPOINTS.malacca);
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.suezNorth);
if (regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
}
// Europe to India via Suez
else if (
(regionA === 'northEurope' || regionA === 'medEurope') &&
regionB === 'india'
) {
if (regionA === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
route.push(WAYPOINTS.suezNorth);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.babElMandeb);
}
// India to Europe via Suez (reverse)
else if (
regionA === 'india' &&
(regionB === 'northEurope' || regionB === 'medEurope')
) {
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.suezNorth);
if (regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
}
// Europe to Middle East via Suez
else if (
(regionA === 'northEurope' || regionA === 'medEurope') &&
regionB === 'middleEast'
) {
if (regionA === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
route.push(WAYPOINTS.suezNorth);
route.push(WAYPOINTS.suezSouth);
}
// Middle East to Europe via Suez (reverse)
else if (
regionA === 'middleEast' &&
(regionB === 'northEurope' || regionB === 'medEurope')
) {
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.suezNorth);
if (regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
}
// Europe to Southeast Asia
else if (
(regionA === 'northEurope' || regionA === 'medEurope') &&
regionB === 'southeastAsia'
) {
if (regionA === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
route.push(WAYPOINTS.suezNorth);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.malacca);
}
// Southeast Asia to Europe (reverse)
else if (
regionA === 'southeastAsia' &&
(regionB === 'northEurope' || regionB === 'medEurope')
) {
route.push(WAYPOINTS.malacca);
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.suezNorth);
if (regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
}
// East Asia to India
else if (
(regionA === 'eastAsia' || regionA === 'southeastAsia') &&
regionB === 'india'
) {
if (regionA === 'eastAsia') {
route.push(WAYPOINTS.hongKong);
}
route.push(WAYPOINTS.malacca);
route.push(WAYPOINTS.sriLanka);
}
// India to East Asia (reverse)
else if (
regionA === 'india' &&
(regionB === 'eastAsia' || regionB === 'southeastAsia')
) {
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.malacca);
if (regionB === 'eastAsia') {
route.push(WAYPOINTS.hongKong);
}
}
// Europe to East Africa
else if (
(regionA === 'northEurope' || regionA === 'medEurope') &&
regionB === 'eastAfrica'
) {
if (regionA === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
route.push(WAYPOINTS.suezNorth);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.babElMandeb);
}
// East Africa to Europe (reverse)
else if (
regionA === 'eastAfrica' &&
(regionB === 'northEurope' || regionB === 'medEurope')
) {
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.suezNorth);
if (regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
}
// Europe to Oceania via Suez
else if (
(regionA === 'northEurope' || regionA === 'medEurope') &&
regionB === 'oceania'
) {
if (regionA === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
route.push(WAYPOINTS.suezNorth);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.malacca);
}
// Oceania to Europe (reverse)
else if (
regionA === 'oceania' &&
(regionB === 'northEurope' || regionB === 'medEurope')
) {
route.push(WAYPOINTS.malacca);
route.push(WAYPOINTS.sriLanka);
route.push(WAYPOINTS.babElMandeb);
route.push(WAYPOINTS.suezSouth);
route.push(WAYPOINTS.suezNorth);
if (regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
}
// North Europe to Med Europe (simple Atlantic)
else if (regionA === 'northEurope' && regionB === 'medEurope') {
route.push(WAYPOINTS.gibraltar);
}
// Med Europe to North Europe (reverse)
else if (regionA === 'medEurope' && regionB === 'northEurope') {
route.push(WAYPOINTS.gibraltar);
}
// East Asia to Oceania
else if (
(regionA === 'eastAsia' || regionA === 'southeastAsia') &&
regionB === 'oceania'
) {
if (regionA === 'eastAsia') {
route.push(WAYPOINTS.hongKong);
route.push(WAYPOINTS.malacca);
}
}
// Oceania to East Asia (reverse)
else if (
regionA === 'oceania' &&
(regionB === 'eastAsia' || regionB === 'southeastAsia')
) {
route.push(WAYPOINTS.malacca);
if (regionB === 'eastAsia') {
route.push(WAYPOINTS.hongKong);
}
}
// Add destination
route.push(portB);
return route;
}
// Component to control map view (fitBounds)
function MapController({
routePoints
}: {
routePoints: Array<{ lat: number; lng: number }>
}) {
const map = useMap();
useEffect(() => {
if (routePoints.length < 2) return;
// Create bounds from all route points
const bounds = L.latLngBounds(
routePoints.map(p => [p.lat, p.lng] as [number, number])
);
// Fit the map to show all points with padding
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 6,
});
}, [map, routePoints]);
return null;
}
export default function PortRouteMap({ portA, portB, height = "500px" }: PortRouteMapProps) {
// Calculate the maritime route with waypoints
const routePoints = useMemo(
() => calculateMaritimeRoute(portA, portB),
[portA.lat, portA.lng, portB.lat, portB.lng]
);
// Convert route points to Leaflet positions
const positions: [number, number][] = routePoints.map(p => [p.lat, p.lng]);
// Calculate initial center (will be adjusted by MapController)
const center = {
lat: (portA.lat + portB.lat) / 2,
lng: (portA.lng + portB.lng) / 2,
};
const positions: [number, number][] = [
[portA.lat, portA.lng],
[portB.lat, portB.lng],
];
return (
<div style={{ height }}>
<MapContainer
center={[center.lat, center.lng]}
zoom={2}
zoom={4}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom={false}
>
@ -374,25 +43,10 @@ export default function PortRouteMap({ portA, portB, height = "500px" }: PortRou
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
{/* Auto-fit bounds to show entire route */}
<MapController routePoints={routePoints} />
{/* Origin marker */}
<Marker position={[portA.lat, portA.lng]} />
{/* Destination marker */}
<Marker position={[portB.lat, portB.lng]} />
{/* Maritime route polyline */}
<Polyline
positions={positions}
pathOptions={{
color: "#2563eb",
weight: 3,
opacity: 0.8,
dashArray: "10, 6",
}}
/>
<Polyline positions={positions} pathOptions={{ color: "#2563eb", weight: 3, opacity: 0.7 }} />
</MapContainer>
</div>
);

View File

@ -3,45 +3,44 @@
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
interface AdminMenuItem {
name: string;
href: string;
icon: LucideIcon;
icon: string;
description: string;
}
const adminMenuItems: AdminMenuItem[] = [
{
name: 'Utilisateurs',
name: 'Users',
href: '/dashboard/admin/users',
icon: Users,
description: 'Gérer les utilisateurs et les permissions',
icon: '👥',
description: 'Manage users and permissions',
},
{
name: 'Organisations',
name: 'Organizations',
href: '/dashboard/admin/organizations',
icon: Building2,
description: 'Gérer les organisations et entreprises',
icon: '🏢',
description: 'Manage organizations and companies',
},
{
name: 'Réservations',
name: 'Bookings',
href: '/dashboard/admin/bookings',
icon: Package,
description: 'Consulter et gérer toutes les réservations',
icon: '📦',
description: 'View and manage all bookings',
},
{
name: 'Documents',
href: '/dashboard/admin/documents',
icon: FileText,
description: 'Gérer les documents des organisations',
icon: '📄',
description: 'Manage organization documents',
},
{
name: 'Tarifs CSV',
name: 'CSV Rates',
href: '/dashboard/admin/csv-rates',
icon: BarChart3,
description: 'Importer et gérer les tarifs CSV',
icon: '📊',
description: 'Upload and manage CSV rates',
},
];
@ -85,8 +84,8 @@ export default function AdminPanelDropdown() {
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Settings className="mr-3 h-5 w-5" />
<span className="flex-1 text-left">Administration</span>
<span className="mr-3 text-xl"></span>
<span className="flex-1 text-left">Admin Panel</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
@ -108,7 +107,6 @@ export default function AdminPanelDropdown() {
<div className="py-2">
{adminMenuItems.map(item => {
const isActive = pathname.startsWith(item.href);
const IconComponent = item.icon;
return (
<Link
key={item.name}
@ -117,7 +115,7 @@ export default function AdminPanelDropdown() {
isActive ? 'bg-blue-50' : ''
}`}
>
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
<span className="text-2xl mr-3 mt-0.5">{item.icon}</span>
<div className="flex-1">
<div className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
{item.name}

View File

@ -9,8 +9,6 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
import Link from 'next/link';
import { UserPlus } from 'lucide-react';
export default function LicensesTab() {
const [error, setError] = useState('');
@ -112,17 +110,10 @@ export default function LicensesTab() {
{/* Active Licenses */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
Licences actives ({activeLicenses.length})
</h3>
<Link
href="/dashboard/settings/users"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<UserPlus className="w-4 h-4 mr-2" />
Inviter un utilisateur
</Link>
</div>
{activeLicenses.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500">
@ -344,6 +335,9 @@ export default function LicensesTab() {
<li>
Chaque utilisateur actif de votre organisation consomme une licence
</li>
<li>
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
</li>
<li>
Les licences sont automatiquement assignées lors de l&apos;ajout d&apos;un
utilisateur

View File

@ -9,8 +9,6 @@
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/lib/context/auth-context';
import { CookieProvider } from '@/lib/context/cookie-context';
import CookieConsent from '@/components/CookieConsent';
export function Providers({ children }: { children: React.ReactNode }) {
// Create a client instance per component instance
@ -29,12 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<CookieProvider>
{children}
<CookieConsent />
</CookieProvider>
</AuthProvider>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
}

View File

@ -165,12 +165,7 @@ export async function apiRequest<T>(
});
// Handle 401 Unauthorized - token expired
// Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors
const isAuthEndpoint = endpoint.includes('/auth/login') ||
endpoint.includes('/auth/register') ||
endpoint.includes('/auth/refresh');
if (response.status === 401 && !isRetry && !isAuthEndpoint) {
if (response.status === 401 && !isRetry && !endpoint.includes('/auth/refresh')) {
// Check if we have a refresh token
const refreshToken = getRefreshToken();
if (!refreshToken) {

View File

@ -5,38 +5,11 @@
*/
import { get, post, patch } from './client';
import type { SuccessResponse } from '@/types/api';
import type {
SuccessResponse,
} from '@/types/api';
/**
* Cookie consent preferences
*/
export interface CookiePreferences {
essential: boolean;
functional: boolean;
analytics: boolean;
marketing: boolean;
}
/**
* Response from consent API
*/
export interface ConsentResponse extends CookiePreferences {
userId: string;
consentDate: string;
updatedAt: string;
}
/**
* Request to update consent
*/
export interface UpdateConsentRequest extends CookiePreferences {
ipAddress?: string;
userAgent?: string;
}
/**
* Data export response
*/
// TODO: These types should be moved to @/types/api.ts
export interface GdprDataExportResponse {
exportId: string;
status: 'PENDING' | 'COMPLETED' | 'FAILED';
@ -45,50 +18,49 @@ export interface GdprDataExportResponse {
downloadUrl?: string;
}
export interface GdprConsentResponse {
userId: string;
marketingEmails: boolean;
dataProcessing: boolean;
thirdPartySharing: boolean;
updatedAt: string;
}
export interface UpdateGdprConsentRequest {
marketingEmails?: boolean;
dataProcessing?: boolean;
thirdPartySharing?: boolean;
}
/**
* Request data export (GDPR right to data portability)
* GET /api/v1/gdpr/export
* Triggers download of JSON file
* POST /api/v1/gdpr/export
* Generates export job and sends download link via email
*/
export async function requestDataExport(): Promise<Blob> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
},
}
);
if (!response.ok) {
throw new Error(`Export failed: ${response.statusText}`);
}
return response.blob();
export async function requestDataExport(): Promise<GdprDataExportResponse> {
return post<GdprDataExportResponse>('/api/v1/gdpr/export');
}
/**
* Request data export as CSV
* GET /api/v1/gdpr/export/csv
* Download exported data
* GET /api/v1/gdpr/export/:exportId/download
* Returns blob (JSON file)
*/
export async function requestDataExportCSV(): Promise<Blob> {
export async function downloadDataExport(exportId: string): Promise<Blob> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`,
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/${exportId}/download`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
}`,
},
}
);
if (!response.ok) {
throw new Error(`Export failed: ${response.statusText}`);
throw new Error(`Download failed: ${response.statusText}`);
}
return response.blob();
@ -96,53 +68,35 @@ export async function requestDataExportCSV(): Promise<Blob> {
/**
* Request account deletion (GDPR right to be forgotten)
* DELETE /api/v1/gdpr/delete-account
* Initiates account deletion process
* POST /api/v1/gdpr/delete-account
* Initiates 30-day account deletion process
*/
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
},
body: JSON.stringify({ confirmEmail, reason }),
export async function requestAccountDeletion(): Promise<SuccessResponse> {
return post<SuccessResponse>('/api/v1/gdpr/delete-account');
}
);
if (!response.ok) {
throw new Error(`Deletion failed: ${response.statusText}`);
}
/**
* Cancel pending account deletion
* POST /api/v1/gdpr/cancel-deletion
*/
export async function cancelAccountDeletion(): Promise<SuccessResponse> {
return post<SuccessResponse>('/api/v1/gdpr/cancel-deletion');
}
/**
* Get user consent preferences
* GET /api/v1/gdpr/consent
*/
export async function getConsentPreferences(): Promise<ConsentResponse | null> {
return get<ConsentResponse | null>('/api/v1/gdpr/consent');
export async function getConsentPreferences(): Promise<GdprConsentResponse> {
return get<GdprConsentResponse>('/api/v1/gdpr/consent');
}
/**
* Update consent preferences
* POST /api/v1/gdpr/consent
* PATCH /api/v1/gdpr/consent
*/
export async function updateConsentPreferences(
data: UpdateConsentRequest
): Promise<ConsentResponse> {
return post<ConsentResponse>('/api/v1/gdpr/consent', data);
}
/**
* Withdraw specific consent
* POST /api/v1/gdpr/consent/withdraw
*/
export async function withdrawConsent(
consentType: 'functional' | 'analytics' | 'marketing'
): Promise<ConsentResponse> {
return post<ConsentResponse>('/api/v1/gdpr/consent/withdraw', { consentType });
data: UpdateGdprConsentRequest
): Promise<GdprConsentResponse> {
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data);
}

View File

@ -107,14 +107,11 @@ export {
// GDPR (6 endpoints)
export {
requestDataExport,
requestDataExportCSV,
downloadDataExport,
requestAccountDeletion,
cancelAccountDeletion,
getConsentPreferences,
updateConsentPreferences,
withdrawConsent,
type CookiePreferences,
type ConsentResponse,
type UpdateConsentRequest,
} from './gdpr';
// Admin CSV Rates (5 endpoints) - already exists

View File

@ -4,7 +4,7 @@
* Endpoints for searching shipping rates (both API and CSV-based)
*/
import { get, post } from './client';
import { post } from './client';
import type {
RateSearchRequest,
RateSearchResponse,
@ -14,37 +14,6 @@ import type {
FilterOptionsResponse,
} from '@/types/api';
/**
* Route Port Info - port details with coordinates
*/
export interface RoutePortInfo {
code: string;
name: string;
city: string;
country: string;
countryName: string;
displayName: string;
latitude?: number;
longitude?: number;
}
/**
* Available Origins Response
*/
export interface AvailableOriginsResponse {
origins: RoutePortInfo[];
total: number;
}
/**
* Available Destinations Response
*/
export interface AvailableDestinationsResponse {
origin: string;
destinations: RoutePortInfo[];
total: number;
}
/**
* Search shipping rates (API-based)
* POST /api/v1/rates/search
@ -89,29 +58,3 @@ export async function getAvailableCompanies(): Promise<AvailableCompaniesRespons
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
return post<FilterOptionsResponse>('/api/v1/rates/filters/options');
}
/**
* Get available origin ports from CSV rates
* GET /api/v1/rates/available-routes/origins
*
* Returns only ports that have shipping routes defined in CSV rate files.
* Use this to populate origin port selection dropdown.
*/
export async function getAvailableOrigins(): Promise<AvailableOriginsResponse> {
return get<AvailableOriginsResponse>('/api/v1/rates/available-routes/origins');
}
/**
* Get available destination ports for a given origin
* GET /api/v1/rates/available-routes/destinations?origin=XXXX
*
* Returns only ports that have shipping routes from the specified origin port.
* Use this to populate destination port selection after origin is selected.
*/
export async function getAvailableDestinations(
origin: string
): Promise<AvailableDestinationsResponse> {
return get<AvailableDestinationsResponse>(
`/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}`
);
}

View File

@ -1,228 +0,0 @@
/**
* Cookie Consent Context
*
* Provides cookie consent state and methods to the application
* Syncs with backend for authenticated users
*/
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import {
getConsentPreferences,
updateConsentPreferences,
type CookiePreferences,
} from '../api/gdpr';
import { getAuthToken } from '../api/client';
const STORAGE_KEY = 'cookieConsent';
const STORAGE_DATE_KEY = 'cookieConsentDate';
interface CookieContextType {
preferences: CookiePreferences;
showBanner: boolean;
showSettings: boolean;
isLoading: boolean;
setShowBanner: (show: boolean) => void;
setShowSettings: (show: boolean) => void;
acceptAll: () => Promise<void>;
acceptEssentialOnly: () => Promise<void>;
savePreferences: (prefs: CookiePreferences) => Promise<void>;
openPreferences: () => void;
}
const defaultPreferences: CookiePreferences = {
essential: true,
functional: false,
analytics: false,
marketing: false,
};
const CookieContext = createContext<CookieContextType | undefined>(undefined);
export function CookieProvider({ children }: { children: React.ReactNode }) {
const [preferences, setPreferences] = useState<CookiePreferences>(defaultPreferences);
const [showBanner, setShowBanner] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [hasInitialized, setHasInitialized] = useState(false);
// Check if user is authenticated
const isAuthenticated = useCallback(() => {
return !!getAuthToken();
}, []);
// Load preferences from localStorage
const loadFromLocalStorage = useCallback((): CookiePreferences | null => {
if (typeof window === 'undefined') return null;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return null;
}, []);
// Save preferences to localStorage
const saveToLocalStorage = useCallback((prefs: CookiePreferences) => {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
localStorage.setItem(STORAGE_DATE_KEY, new Date().toISOString());
}, []);
// Apply preferences (gtag, etc.)
const applyPreferences = useCallback((prefs: CookiePreferences) => {
if (typeof window === 'undefined') return;
// Google Analytics consent
const gtag = (window as any).gtag;
if (gtag) {
gtag('consent', 'update', {
analytics_storage: prefs.analytics ? 'granted' : 'denied',
ad_storage: prefs.marketing ? 'granted' : 'denied',
functionality_storage: prefs.functional ? 'granted' : 'denied',
});
}
// Sentry (if analytics enabled)
const Sentry = (window as any).Sentry;
if (Sentry) {
if (prefs.analytics) {
Sentry.init && Sentry.getCurrentHub && Sentry.getCurrentHub().getClient()?.getOptions();
}
}
}, []);
// Initialize preferences
useEffect(() => {
const initializePreferences = async () => {
setIsLoading(true);
// First, check localStorage
const localPrefs = loadFromLocalStorage();
if (localPrefs) {
setPreferences(localPrefs);
applyPreferences(localPrefs);
setShowBanner(false);
} else {
// No local consent - show banner
setShowBanner(true);
}
// If authenticated, try to sync with backend
if (isAuthenticated() && localPrefs) {
try {
const backendPrefs = await getConsentPreferences();
if (backendPrefs) {
// Backend has preferences - use them
const prefs: CookiePreferences = {
essential: backendPrefs.essential,
functional: backendPrefs.functional,
analytics: backendPrefs.analytics,
marketing: backendPrefs.marketing,
};
setPreferences(prefs);
saveToLocalStorage(prefs);
applyPreferences(prefs);
} else if (localPrefs) {
// No backend preferences but we have local - sync to backend
try {
await updateConsentPreferences({
...localPrefs,
userAgent: navigator.userAgent,
});
} catch (syncError) {
console.warn('Failed to sync consent to backend:', syncError);
}
}
} catch (error) {
console.warn('Failed to fetch consent from backend:', error);
// Continue with local preferences
}
}
setIsLoading(false);
setHasInitialized(true);
};
initializePreferences();
}, [isAuthenticated, loadFromLocalStorage, saveToLocalStorage, applyPreferences]);
// Save preferences (to localStorage and optionally backend)
const savePreferences = useCallback(
async (prefs: CookiePreferences) => {
// Ensure essential is always true
const safePrefs = { ...prefs, essential: true };
setPreferences(safePrefs);
saveToLocalStorage(safePrefs);
applyPreferences(safePrefs);
setShowBanner(false);
setShowSettings(false);
// Sync to backend if authenticated
if (isAuthenticated()) {
try {
await updateConsentPreferences({
...safePrefs,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
});
} catch (error) {
console.warn('Failed to sync consent to backend:', error);
// Local save succeeded, backend sync failed - that's okay
}
}
},
[isAuthenticated, saveToLocalStorage, applyPreferences]
);
const acceptAll = useCallback(async () => {
await savePreferences({
essential: true,
functional: true,
analytics: true,
marketing: true,
});
}, [savePreferences]);
const acceptEssentialOnly = useCallback(async () => {
await savePreferences({
essential: true,
functional: false,
analytics: false,
marketing: false,
});
}, [savePreferences]);
const openPreferences = useCallback(() => {
setShowBanner(true);
setShowSettings(true);
}, []);
const value: CookieContextType = {
preferences,
showBanner,
showSettings,
isLoading,
setShowBanner,
setShowSettings,
acceptAll,
acceptEssentialOnly,
savePreferences,
openPreferences,
};
return <CookieContext.Provider value={value}>{children}</CookieContext.Provider>;
}
export function useCookieConsent() {
const context = useContext(CookieContext);
if (context === undefined) {
throw new Error('useCookieConsent must be used within a CookieProvider');
}
return context;
}