Compare commits

..

8 Commits

Author SHA1 Message Date
David
9bed6b54a7 fix 404
Some checks failed
CI/CD Pipeline / Backend - Build, Test & Push (push) Failing after 10m59s
CI/CD Pipeline / Frontend - Build, Test & Push (push) Successful in 37m59s
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deployment Summary (push) Has been skipped
CI/CD Pipeline / Deploy to Portainer (push) Has been skipped
CI/CD Pipeline / Discord Notification (Success) (push) Has been skipped
CI/CD Pipeline / Discord Notification (Failure) (push) Has been skipped
2026-02-10 21:50:40 +01:00
David
baf5981847 fix improve 2026-02-10 17:16:35 +01:00
David
fd1f57dd1d fix 2026-02-05 11:53:22 +01:00
David
1d279a0e12 fix 2026-02-04 21:51:03 +01:00
David
1a86864d1f fix 2026-02-03 22:14:03 +01:00
David
cf19c36586 fix ui 2026-02-03 16:08:00 +01:00
David
3e654af8a3 fix 2026-01-27 19:57:15 +01:00
David
4c7b07a911 fix error login 2026-01-27 19:33:51 +01:00
64 changed files with 5127 additions and 1949 deletions

1095
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,12 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } 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)
@ -88,4 +93,84 @@ 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,13 +14,20 @@ import {
HttpCode,
HttpStatus,
Res,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
import { Response, Request } 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, ConsentData } from '../services/gdpr.service';
import { GDPRService } from '../services/gdpr.service';
import {
UpdateConsentDto,
ConsentResponseDto,
WithdrawConsentDto,
ConsentSuccessDto,
} from '../dto/consent.dto';
@ApiTags('GDPR')
@Controller('gdpr')
@ -77,6 +84,13 @@ 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(
@ -119,22 +133,26 @@ export class GDPRController {
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Record user consent',
description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)',
description: 'Record consent for cookies (GDPR Article 7)',
})
@ApiResponse({
status: 200,
description: 'Consent recorded',
type: ConsentResponseDto,
})
async recordConsent(
@CurrentUser() user: UserPayload,
@Body() body: Omit<ConsentData, 'userId'>
): Promise<{ success: boolean }> {
await this.gdprService.recordConsent({
@Body() body: UpdateConsentDto,
@Req() req: Request
): Promise<ConsentResponseDto> {
// Add IP and user agent from request if not provided
const consentData: UpdateConsentDto = {
...body,
userId: user.id,
});
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
userAgent: body.userAgent || req.headers['user-agent'],
};
return { success: true };
return this.gdprService.recordConsent(user.id, consentData);
}
/**
@ -144,19 +162,18 @@ export class GDPRController {
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Withdraw consent',
description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)',
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
})
@ApiResponse({
status: 200,
description: 'Consent withdrawn',
type: ConsentResponseDto,
})
async withdrawConsent(
@CurrentUser() user: UserPayload,
@Body() body: { consentType: 'marketing' | 'analytics' }
): Promise<{ success: boolean }> {
await this.gdprService.withdrawConsent(user.id, body.consentType);
return { success: true };
@Body() body: WithdrawConsentDto
): Promise<ConsentResponseDto> {
return this.gdprService.withdrawConsent(user.id, body.consentType);
}
/**
@ -170,8 +187,9 @@ export class GDPRController {
@ApiResponse({
status: 200,
description: 'Consent status retrieved',
type: ConsentResponseDto,
})
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
return this.gdprService.getConsentStatus(user.id);
}
}

View File

@ -3,12 +3,14 @@ import {
Post,
Get,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
UseGuards,
Inject,
} from '@nestjs/common';
import {
ApiTags,
@ -17,6 +19,7 @@ import {
ApiBadRequestResponse,
ApiInternalServerErrorResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
import { RateQuoteMapper } from '../mappers';
@ -25,8 +28,15 @@ 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 } from '../dto/csv-rate-upload.dto';
import {
AvailableCompaniesDto,
FilterOptionsDto,
AvailableOriginsDto,
AvailableDestinationsDto,
RoutePortInfoDto,
} 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')
@ -37,7 +47,8 @@ export class RatesController {
constructor(
private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper
private readonly csvRateMapper: CsvRateMapper,
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
) {}
@Post('search')
@ -271,6 +282,168 @@ 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

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

@ -0,0 +1,139 @@
/**
* 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,6 +201,12 @@ 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,3 +209,101 @@ 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,6 +12,7 @@ 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: [
@ -20,6 +21,7 @@ import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/
BookingOrmEntity,
AuditLogOrmEntity,
NotificationOrmEntity,
CookieConsentOrmEntity,
]),
],
controllers: [GDPRController],

View File

@ -1,5 +1,13 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
Inject,
UnauthorizedException,
} 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';
@ -21,6 +29,7 @@ import {
CsvBookingListResponseDto,
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
/**
* CSV Booking Document (simple class for domain)
@ -56,6 +65,27 @@ 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
*/
@ -72,9 +102,14 @@ export class CsvBookingService {
throw new BadRequestException('At least one document is required');
}
// Generate unique confirmation token
// Generate unique confirmation token and booking number
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);
@ -106,13 +141,26 @@ export class CsvBookingService {
// Save to database
const savedBooking = await this.csvBookingRepository.create(booking);
this.logger.log(`CSV booking created with ID: ${bookingId}`);
// 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}`);
// 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,
@ -128,6 +176,7 @@ export class CsvBookingService {
fileName: doc.fileName,
})),
confirmationToken,
notes: dto.notes,
});
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) {
@ -201,6 +250,130 @@ 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
*/
@ -213,6 +386,11 @@ 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();
@ -220,6 +398,31 @@ 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({
@ -475,9 +678,9 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify booking is still pending
if (booking.status !== CsvBookingStatus.PENDING) {
throw new BadRequestException('Cannot add documents to a booking that is not pending');
// 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');
}
// Upload new documents
@ -506,6 +709,24 @@ 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',
@ -701,6 +922,7 @@ export class CsvBookingService {
return {
id: booking.id,
bookingNumber: booking.bookingNumber,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,

View File

@ -1,37 +1,35 @@
/**
* GDPR Compliance Service - Simplified Version
* GDPR Compliance Service
*
* 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>
private readonly userRepository: Repository<UserOrmEntity>,
@InjectRepository(CookieConsentOrmEntity)
private readonly consentRepository: Repository<CookieConsentOrmEntity>
) {}
/**
@ -46,6 +44,9 @@ 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,
@ -63,6 +64,15 @@ 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.',
};
@ -88,6 +98,9 @@ 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:
@ -105,55 +118,139 @@ export class GDPRService {
}
/**
* Record consent (GDPR Article 7 - Conditions for consent)
* Record or update consent (GDPR Article 7 - Conditions for consent)
*/
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 },
});
if (!user) {
throw new NotFoundException('User not found');
}
// 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 consent (GDPR Article 7.3 - Withdrawal of consent)
*/
async withdrawConsent(userId: string, consentType: 'marketing' | 'analytics'): Promise<void> {
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
async recordConsent(
userId: string,
consentData: UpdateConsentDto
): Promise<ConsentResponseDto> {
this.logger.log(`Recording consent for user ${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,
};
}
/**
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
*/
async withdrawConsent(
userId: string,
consentType: 'functional' | 'analytics' | 'marketing'
): Promise<ConsentResponseDto> {
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<any> {
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
// Verify user exists
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
// Default consent status
// 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;
}
return {
marketing: false,
analytics: false,
functional: true,
message: 'Consent management fully implemented in production version',
userId,
essential: consent.essential,
functional: consent.functional,
analytics: consent.analytics,
marketing: consent.marketing,
consentDate: consent.consentDate,
updatedAt: consent.updatedAt,
};
}
}

View File

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

View File

@ -85,6 +85,8 @@ export interface EmailPort {
carrierEmail: string,
bookingDetails: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -100,6 +102,7 @@ export interface EmailPort {
fileName: string;
}>;
confirmationToken: string;
notes?: string;
}
): Promise<void>;
@ -120,4 +123,39 @@ 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,6 +239,60 @@ 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,6 +239,8 @@ export class EmailAdapter implements EmailPort {
carrierEmail: string,
bookingData: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -254,6 +256,7 @@ export class EmailAdapter implements EmailPort {
fileName: string;
}>;
confirmationToken: string;
notes?: string;
}
): Promise<void> {
// Use APP_URL (frontend) for accept/reject links
@ -270,7 +273,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: carrierEmail,
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`,
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin}${bookingData.destination}`,
html,
});
@ -427,4 +430,194 @@ 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,6 +261,8 @@ export class EmailTemplates {
*/
async renderCsvBookingRequest(data: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -275,6 +277,7 @@ export class EmailTemplates {
type: string;
fileName: string;
}>;
notes?: string;
acceptUrl: string;
rejectUrl: string;
}): Promise<string> {
@ -481,6 +484,21 @@ 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">
@ -540,6 +558,14 @@ 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

@ -0,0 +1,58 @@
/**
* 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,6 +96,13 @@ 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,7 +41,8 @@ export class CsvBookingMapper {
ormEntity.requestedAt,
ormEntity.respondedAt,
ormEntity.notes,
ormEntity.rejectionReason
ormEntity.rejectionReason,
ormEntity.bookingNumber ?? undefined
);
}

View File

@ -0,0 +1,62 @@
/**
* 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

@ -0,0 +1,48 @@
/**
* 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

@ -0,0 +1,568 @@
'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,6 +2,8 @@
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;
@ -226,30 +228,31 @@ export default function AdminDocumentsPage() {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
const getDocumentIcon = (type: string) => {
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
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: '📄',
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`} />,
};
return icons[typeLower] || '📎';
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
};
const getStatusColor = (status: string) => {
@ -445,7 +448,7 @@ export default function AdminDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-2xl mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>

View File

@ -8,6 +8,7 @@
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';
@ -49,6 +50,7 @@ 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>({
@ -187,7 +189,7 @@ function NewBookingPageContent() {
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
const canProceedToStep3 = formData.documents.length >= 1;
const canSubmit = canProceedToStep3;
const canSubmit = canProceedToStep3 && termsAccepted;
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('fr-FR', {
@ -217,10 +219,10 @@ function NewBookingPageContent() {
</div>
{/* Progress Steps */}
<div className="mb-8">
<div className="mb-8 bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
{[1, 2, 3].map(step => (
<div key={step} className="flex items-center flex-1">
<div key={step} className={`flex items-center ${step < 3 ? 'flex-1' : ''}`}>
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
@ -259,7 +261,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">
<span className="text-2xl mr-3"></span>
<AlertTriangle className="h-6 w-6 mr-3 text-red-500 flex-shrink-0" />
<div>
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
<p className="text-red-700 whitespace-pre-line">{error}</p>
@ -384,7 +386,7 @@ function NewBookingPageContent() {
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<span className="text-2xl mr-3">📋</span>
<ClipboardList className="h-6 w-6 mr-3 text-yellow-700 flex-shrink-0" />
<div>
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
<p className="text-sm text-yellow-800">
@ -572,7 +574,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">
📧 Que se passe-t-il ensuite ?
<Mail className="h-5 w-5 mr-2 inline-block" /> Que se passe-t-il ensuite ?
</h3>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start">
@ -607,8 +609,9 @@ 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,6 +10,8 @@ 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';
@ -146,14 +148,38 @@ 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"
>
<span className="mr-2"></span>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4">
@ -262,9 +288,12 @@ 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-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -326,11 +355,14 @@ export default function BookingsListPage() {
})
: 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td className="px-6 py-4 whitespace-nowrap text-left 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>
@ -450,7 +482,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"
>
<span className="mr-2"></span>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>

View File

@ -2,6 +2,9 @@
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;
@ -164,30 +167,31 @@ export default function UserDocumentsPage() {
setCurrentPage(1);
}, [searchTerm, filterStatus, filterQuoteNumber]);
const getDocumentIcon = (type: string) => {
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
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: '📄',
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`} />,
};
return icons[typeLower] || '📎';
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
};
const getStatusColor = (status: string) => {
@ -255,8 +259,10 @@ export default function UserDocumentsPage() {
}
};
// Get unique bookings for add document modal
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
// Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
);
const handleAddDocumentClick = () => {
setShowAddModal(true);
@ -407,9 +413,31 @@ 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={bookingsWithPendingStatus.length === 0}
disabled={bookingsAvailableForDocuments.length === 0}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -423,6 +451,7 @@ export default function UserDocumentsPage() {
Ajouter un document
</button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@ -533,7 +562,7 @@ export default function UserDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-2xl mr-2">
<span className="mr-2">
{getDocumentIcon(doc.fileType || doc.type)}
</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
@ -786,7 +815,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 (en attente)
Sélectionner une réservation
</label>
<select
value={selectedBookingId || ''}
@ -794,9 +823,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>
{bookingsWithPendingStatus.map(booking => (
{bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination}
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
</option>
))}
</select>

View File

@ -13,25 +13,32 @@ 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: '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: '🏢' },
{ 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 },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
] : []),
];
@ -98,7 +105,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className="mr-3 text-xl">{item.icon}</span>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
))}
@ -129,15 +136,8 @@ 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"
>
<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
<LogOut className="w-4 h-4 mr-2" />
Déconnexion
</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 || 'Dashboard'}
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
</h1>
</div>
<div className="flex items-center space-x-4">
{/* Notifications */}
<NotificationDropdown />
{/* User Role Badge */}
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{user?.role}
</span>
{/* User Initials */}
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Link>
</div>
</div>

View File

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

View File

@ -21,6 +21,7 @@ import {
Plus,
ArrowRight,
} from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import {
PieChart,
Pie,
@ -74,17 +75,33 @@ 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 bookings et performances
Vue d'ensemble de vos réservations et performances
</p>
</div>
<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
<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
</Button>
</Link>
</div>
</div>
{/* KPI Cards - Compact with Color */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@ -191,7 +208,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 Bookings
Distribution des Réservations
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Répartition par statut
@ -233,7 +250,7 @@ export default function DashboardPage() {
Poids par Transporteur
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Top 5 carriers par poids (KG)
Top 5 transporteurs par poids (KG)
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -282,7 +299,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 Bookings</p>
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
@ -360,7 +377,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} bookings</span>
<span>{carrier.totalBookings} réservations</span>
<span></span>
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
</div>
@ -400,15 +417,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">
Aucun booking
Aucune réservation
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
Créez votre premier booking pour voir vos statistiques
Créez votre première réservation pour voir vos statistiques
</p>
<Link href="/dashboard/bookings">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-1.5 h-3 w-3" />
Créer un booking
Créer une réservation
</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, 'Current password is required'),
currentPassword: z.string().min(1, 'Le mot de passe actuel est requis'),
newPassword: z
.string()
.min(12, 'Password must be at least 12 characters')
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
'Password must contain uppercase, lowercase, number, and special character'
'Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial'
),
confirmPassword: z.string().min(1, 'Please confirm your password'),
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
@ -36,9 +36,9 @@ type PasswordFormData = z.infer<typeof passwordSchema>;
// Profile update schema
const profileSchema = z.object({
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'),
firstName: z.string().min(2, 'Le prénom doit contenir au moins 2 caractères'),
lastName: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
email: z.string().email('Adresse email invalide'),
});
type ProfileFormData = z.infer<typeof profileSchema>;
@ -101,14 +101,14 @@ export default function ProfilePage() {
return updateUser(user.id, data);
},
onSuccess: () => {
setSuccessMessage('Profile updated successfully!');
setSuccessMessage('Profil mis à jour avec succès !');
setErrorMessage('');
refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Failed to update profile');
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setSuccessMessage('');
},
});
@ -122,7 +122,7 @@ export default function ProfilePage() {
});
},
onSuccess: () => {
setSuccessMessage('Password updated successfully!');
setSuccessMessage('Mot de passe mis à jour avec succès !');
setErrorMessage('');
passwordForm.reset({
currentPassword: '',
@ -132,7 +132,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Failed to update password');
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
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">Loading profile...</p>
<p className="text-gray-600">Chargement du profil...</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">Unable to load user profile</p>
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
Réessayer
</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">My Profile</h1>
<p className="text-blue-100">Manage your account settings and preferences</p>
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
</div>
{/* Success/Error Messages */}
@ -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">
Active
Actif
</span>
</div>
</div>
@ -249,7 +249,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile Information
Informations personnelles
</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'
}`}
>
Change Password
Modifier le mot de passe
</button>
</nav>
</div>
@ -274,7 +274,7 @@ export default function ProfilePage() {
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
First Name
Prénom
</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"
>
Last Name
Nom
</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">
Email Address
Adresse email
</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">Email cannot be changed</p>
<p className="mt-1 text-xs text-gray-500">L&apos;adresse email ne peut pas être modifiée</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 ? 'Saving...' : 'Save Changes'}
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
@ -345,7 +345,7 @@ export default function ProfilePage() {
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Current Password
Mot de passe actuel
</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"
>
New Password
Nouveau mot de passe
</label>
<input
{...passwordForm.register('newPassword')}
@ -382,8 +382,7 @@ export default function ProfilePage() {
</p>
)}
<p className="mt-1 text-xs text-gray-500">
Must be at least 12 characters with uppercase, lowercase, number, and special
character
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
</p>
</div>
@ -393,7 +392,7 @@ export default function ProfilePage() {
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirm New Password
Confirmer le nouveau mot de passe
</label>
<input
{...passwordForm.register('confirmPassword')}
@ -416,7 +415,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 ? 'Updating...' : 'Update Password'}
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
</button>
</div>
</form>

View File

@ -2,14 +2,20 @@
* 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, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { searchPorts, Port } from '@/lib/api/ports';
import {
getAvailableOrigins,
getAvailableDestinations,
RoutePortInfo,
} from '@/lib/api/rates';
import dynamic from 'next/dynamic';
// Import dynamique pour éviter les erreurs SSR avec Leaflet
@ -92,24 +98,60 @@ export default function AdvancedSearchPage() {
const [destinationSearch, setDestinationSearch] = useState('');
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
const [selectedOriginPort, setSelectedOriginPort] = useState<Port | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<Port | null>(null);
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
// Port autocomplete queries
const { data: originPortsData } = useQuery({
queryKey: ['ports', originSearch],
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
enabled: originSearch.length >= 2 && showOriginDropdown,
// Fetch available origins from CSV rates
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'],
queryFn: getAvailableOrigins,
});
const { data: destinationPortsData } = useQuery({
queryKey: ['ports', destinationSearch],
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
enabled: destinationSearch.length >= 2 && showDestinationDropdown,
// 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 originPorts = originPortsData?.ports || [];
const destinationPorts = destinationPortsData?.ports || [];
// 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]);
// Calculate total volume and weight
const calculateTotals = () => {
@ -188,37 +230,51 @@ 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 */}
{/* Origin Port with Autocomplete - Limited to CSV routes */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs"> Sélectionné</span>}
</label>
<div className="relative">
<input
type="text"
value={originSearch}
onChange={e => {
setOriginSearch(e.target.value);
setShowOriginDropdown(true);
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, origin: '' });
// Clear selection if user modifies the input
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
setSelectedDestinationPort(null);
setDestinationSearch('');
}
}}
onFocus={() => setShowOriginDropdown(true)}
placeholder="ex: Rotterdam, Paris, FRPAR"
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder="Rechercher un port d'origine..."
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
}`}
/>
{showOriginDropdown && originPorts && originPorts.length > 0 && (
{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 && (
<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">
{originPorts.map((port: Port) => (
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, origin: port.code });
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
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"
@ -229,34 +285,60 @@ 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 */}
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs"> Sélectionné</span>}
</label>
<div className="relative">
<input
type="text"
value={destinationSearch}
onChange={e => {
setDestinationSearch(e.target.value);
setShowDestinationDropdown(true);
if (e.target.value.length < 2) {
// Clear selection if user modifies the input
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null);
}
}}
onFocus={() => setShowDestinationDropdown(true)}
placeholder="ex: Shanghai, New York, CNSHA"
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin}
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
}`}
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
/>
{showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && (
{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 && (
<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">
{destinationPorts.map((port: Port) => (
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
<button
key={port.code}
type="button"
@ -274,13 +356,23 @@ 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 && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">
@ -292,12 +384,12 @@ export default function AdvancedSearchPage() {
</div>
<PortRouteMap
portA={{
lat: selectedOriginPort.coordinates.latitude,
lng: selectedOriginPort.coordinates.longitude,
lat: selectedOriginPort.latitude,
lng: selectedOriginPort.longitude!,
}}
portB={{
lat: selectedDestinationPort.coordinates.latitude,
lng: selectedDestinationPort.coordinates.longitude,
lat: selectedDestinationPort.latitude,
lng: selectedDestinationPort.longitude!,
}}
height="400px"
/>
@ -641,7 +733,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination}
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
🔍 Rechercher les tarifs
<Search className="h-5 w-5 mr-2" /> Rechercher les tarifs
</button>
)}
</div>

View File

@ -4,6 +4,7 @@ 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;
@ -121,7 +122,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="text-6xl mb-4"></div>
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
@ -148,13 +149,13 @@ export default function SearchResultsPage() {
</button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
<div className="text-6xl mb-4">🔍</div>
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
<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">💡 Suggestions :</h4>
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
<ul className="text-sm text-gray-700 space-y-2">
<li>
<strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX,
@ -190,7 +191,7 @@ export default function SearchResultsPage() {
text: 'text-green-800',
button: 'bg-green-600 hover:bg-green-700',
},
icon: '💰',
icon: <DollarSign className="h-10 w-10 text-green-600" />,
badge: 'Le moins cher',
},
{
@ -202,7 +203,7 @@ export default function SearchResultsPage() {
text: 'text-blue-800',
button: 'bg-blue-600 hover:bg-blue-700',
},
icon: '⚖️',
icon: <Scale className="h-10 w-10 text-blue-600" />,
badge: 'Équilibré',
},
{
@ -214,7 +215,7 @@ export default function SearchResultsPage() {
text: 'text-purple-800',
button: 'bg-purple-600 hover:bg-purple-700',
},
icon: '⚡',
icon: <Zap className="h-10 w-10 text-purple-600" />,
badge: 'Le plus rapide',
},
];
@ -253,7 +254,7 @@ export default function SearchResultsPage() {
{bestOptions && (
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-3xl mr-3">🏆</span>
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
Meilleurs choix pour votre recherche
</h2>
@ -269,7 +270,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 className="text-4xl">{card.icon}</span>
<span>{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">
@ -366,7 +367,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"> Surcharges applicables</span>}
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
</div>
<button
onClick={() => {

View File

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

View File

@ -13,6 +13,7 @@ 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();
@ -53,7 +54,7 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.');
setShowInviteModal(false);
setInviteForm({
email: '',
@ -64,7 +65,7 @@ export default function UsersManagementPage() {
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to send invitation');
setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation');
setTimeout(() => setError(''), 5000);
},
});
@ -75,11 +76,11 @@ export default function UsersManagementPage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Role updated successfully');
setSuccess('Rôle mis à jour avec succès');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update role');
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
setTimeout(() => setError(''), 5000);
},
});
@ -91,11 +92,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('User status updated successfully');
setSuccess('Statut de l\'utilisateur mis à jour avec succès');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update user status');
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
setTimeout(() => setError(''), 5000);
},
});
@ -105,11 +106,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('User deleted successfully');
setSuccess('Utilisateur supprimé avec succès');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete user');
setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur');
setTimeout(() => setError(''), 5000);
},
});
@ -143,7 +144,7 @@ export default function UsersManagementPage() {
const handleToggleActive = (userId: string, isActive: boolean) => {
if (
window.confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this user?`)
window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)
) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
@ -151,7 +152,7 @@ export default function UsersManagementPage() {
const handleDelete = (userId: string) => {
if (
window.confirm('Are you sure you want to delete this user? This action cannot be undone.')
window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')
) {
deleteMutation.mutate(userId);
}
@ -179,17 +180,17 @@ export default function UsersManagementPage() {
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
<p className="mt-1 text-sm text-amber-700">
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Upgrade your subscription to invite more users.
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
</p>
<div className="mt-3">
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
>
Upgrade Subscription
Mettre à niveau l'abonnement
</Link>
</div>
</div>
@ -206,14 +207,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} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
</span>
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Manage Subscription
Gérer l'abonnement
</Link>
</div>
</div>
@ -222,16 +223,37 @@ export default function UsersManagementPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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>
<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>
</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>
Invite User
Inviter un utilisateur
</button>
) : (
<Link
@ -239,10 +261,11 @@ 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>
Upgrade to Invite
Mettre à niveau
</Link>
)}
</div>
</div>
{success && (
<div className="rounded-md bg-green-50 p-4">
@ -261,7 +284,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>
Loading users...
Chargement des utilisateurs...
</div>
) : users?.users && users.users.length > 0 ? (
<div className="overflow-x-auto overflow-y-visible">
@ -269,19 +292,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">
User
Utilisateur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
Rôle
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
Date de création
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
@ -338,7 +361,7 @@ export default function UsersManagementPage() {
: 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -386,8 +409,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">No users</h3>
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
<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>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button
@ -490,7 +513,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">Invite User</h3>
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
<button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
@ -510,7 +533,7 @@ export default function UsersManagementPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
First Name *
Prénom *
</label>
<input
type="text"
@ -521,7 +544,7 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
<label className="block text-sm font-medium text-gray-700">Nom *</label>
<input
type="text"
required
@ -533,7 +556,7 @@ export default function UsersManagementPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email *</label>
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
<input
type="email"
required
@ -544,20 +567,20 @@ export default function UsersManagementPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role *</label>
<label className="block text-sm font-medium text-gray-700">Rôle *</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">User</option>
<option value="USER">Utilisateur</option>
<option value="MANAGER">Manager</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="VIEWER">Viewer</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
<option value="VIEWER">Lecteur</option>
</select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">
Only platform administrators can assign the ADMIN role
Seuls les administrateurs peuvent attribuer le rôle ADMIN
</p>
)}
</div>
@ -568,14 +591,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 ? 'Inviting...' : 'Send Invitation'}
{inviteMutation.isPending ? 'Envoi en cours...' : 'Envoyer l\'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"
>
Cancel
Annuler
</button>
</div>
</form>

View File

@ -2,107 +2,181 @@
* Track & Trace Page
*
* Allows users to track their shipments by entering tracking numbers
* and selecting the carrier. Redirects to carrier's tracking page.
* and selecting the carrier. Includes search history and vessel position map.
*/
'use client';
import { useState } from 'react';
import { useState, useEffect } 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';
// Carrier tracking URLs - the tracking number will be appended
// Search history item type
interface SearchHistoryItem {
id: string;
trackingNumber: string;
carrierId: string;
carrierName: string;
timestamp: Date;
}
// Carrier tracking URLs with official brand colors
const carriers = [
{
id: 'maersk',
name: 'Maersk',
logo: '🚢',
color: '#00243D', // Maersk dark blue
textColor: 'text-white',
trackingUrl: 'https://www.maersk.com/tracking/',
placeholder: 'Ex: MSKU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/maersk.svg',
},
{
id: 'msc',
name: 'MSC',
logo: '🛳️',
color: '#002B5C', // MSC blue
textColor: 'text-white',
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
placeholder: 'Ex: MSCU1234567',
description: 'Container, B/L or Booking number',
description: 'N° conteneur, B/L ou réservation',
logo: '/assets/logos/carriers/msc.svg',
},
{
id: 'cma-cgm',
name: 'CMA CGM',
logo: '⚓',
color: '#E30613', // CMA CGM red
textColor: 'text-white',
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
placeholder: 'Ex: CMAU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cmacgm.svg',
},
{
id: 'hapag-lloyd',
name: 'Hapag-Lloyd',
logo: '🔷',
color: '#FF6600', // Hapag orange
textColor: 'text-white',
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
placeholder: 'Ex: HLCU1234567',
description: 'Container number',
description: 'N° conteneur',
logo: '/assets/logos/carriers/hapag.svg',
},
{
id: 'cosco',
name: 'COSCO',
logo: '🌊',
color: '#003A70', // COSCO blue
textColor: 'text-white',
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
placeholder: 'Ex: COSU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cosco.svg',
},
{
id: 'one',
name: 'ONE (Ocean Network Express)',
logo: '🟣',
name: 'ONE',
color: '#FF00FF', // ONE magenta
textColor: 'text-white',
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
placeholder: 'Ex: ONEU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/one.svg',
},
{
id: 'evergreen',
name: 'Evergreen',
logo: '🌲',
color: '#006633', // Evergreen green
textColor: 'text-white',
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
placeholder: 'Ex: EGHU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/evergreen.svg',
},
{
id: 'yangming',
name: 'Yang Ming',
logo: '🟡',
color: '#FFD700', // Yang Ming yellow
textColor: 'text-gray-900',
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
description: 'Container number',
description: 'N° conteneur',
logo: '/assets/logos/carriers/yangming.svg',
},
{
id: 'zim',
name: 'ZIM',
logo: '🔵',
color: '#1E3A8A', // ZIM blue
textColor: 'text-white',
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
placeholder: 'Ex: ZIMU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/zim.svg',
},
{
id: 'hmm',
name: 'HMM (Hyundai)',
logo: '🟠',
name: 'HMM',
color: '#E65100', // HMM orange
textColor: 'text-white',
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
placeholder: 'Ex: HDMU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/hmm.svg',
},
];
// 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;
@ -114,15 +188,43 @@ 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();
@ -131,11 +233,25 @@ 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">Track & Trace</h1>
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1>
<p className="mt-2 text-gray-600">
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
</p>
@ -145,15 +261,15 @@ export default function TrackTracePage() {
<Card className="bg-white shadow-lg border-blue-100">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<span className="text-2xl">🔍</span>
<Search className="h-5 w-5 text-blue-600" />
Rechercher une expédition
</CardTitle>
<CardDescription>
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Carrier Selection */}
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Sélectionnez le transporteur
@ -167,14 +283,20 @@ export default function TrackTracePage() {
setSelectedCarrier(carrier.id);
setError('');
}}
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all hover:scale-105 ${
selectedCarrier === carrier.id
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
? 'border-blue-500 shadow-lg ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
}`}
>
<span className="text-2xl mb-1">{carrier.logo}</span>
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
{/* 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>
</button>
))}
</div>
@ -197,22 +319,42 @@ export default function TrackTracePage() {
}}
onKeyPress={handleKeyPress}
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
className="text-lg font-mono border-gray-300 focus:border-blue-500"
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
/>
{selectedCarrierData && (
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
)}
</div>
{/* US 5.2: Harmonized button color */}
<Button
onClick={handleTrack}
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
>
<span className="mr-2">🔍</span>
<Search className="mr-2 h-5 w-5" />
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">
@ -222,12 +364,221 @@ 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">
<span>📦</span>
<Package className="h-5 w-5 text-blue-600" />
Numéro de conteneur
</CardTitle>
</CardHeader>
@ -242,14 +593,14 @@ export default function TrackTracePage() {
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span>📋</span>
<FileText className="h-5 w-5 text-blue-600" />
Connaissement (B/L)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
Format variable selon le carrier.
Le numéro de connaissement (Bill of Lading) est fourni par le transporteur lors de la confirmation de réservation.
Le format varie selon le transporteur.
</p>
</CardContent>
</Card>
@ -257,8 +608,8 @@ export default function TrackTracePage() {
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span>📝</span>
Référence de booking
<ClipboardList className="h-5 w-5 text-blue-600" />
Référence de réservation
</CardTitle>
</CardHeader>
<CardContent>
@ -272,7 +623,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">
<span className="text-xl">💡</span>
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
<p className="text-sm text-blue-700 mt-1">

View File

@ -6,6 +6,7 @@
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 = [
{
@ -64,7 +65,7 @@ export default function AssurancePage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🛡</span>
<Shield className="w-10 h-10 text-blue-600" />
<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">
@ -88,7 +89,7 @@ export default function AssurancePage() {
{/* ICC Clauses */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
<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>
<div className="space-y-4">
{clausesICC.map((clause) => (
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
@ -138,7 +139,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">💰 Calcul de la Valeur Assurée</h3>
<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>
<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">
@ -165,7 +166,7 @@ export default function AssurancePage() {
{/* Extensions */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Extensions de Garantie</h2>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{extensionsGaranties.map((ext) => (
<Card key={ext.name} className="bg-white">
@ -181,7 +182,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">📝 En Cas de Sinistre</h3>
<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>
<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>
@ -195,7 +196,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">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> 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,6 +6,7 @@
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 = [
{
@ -86,7 +87,7 @@ export default function CalculFretPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🧮</span>
<Calculator className="w-10 h-10 text-blue-600" />
<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">
@ -98,7 +99,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">📐 Principes de Base</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> 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>
@ -121,7 +122,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"> Poids Taxable (LCL)</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Scale className="w-5 h-5" /> 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">
@ -157,7 +158,7 @@ export default function CalculFretPage() {
{/* Surcharges */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{surcharges.map((sur) => (
<Card key={sur.code} className="bg-white">
@ -180,7 +181,7 @@ export default function CalculFretPage() {
{/* Additional fees */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -210,7 +211,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">📊 Exemple de Devis FCL</h3>
<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>
<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">
@ -254,7 +255,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">💡 Conseils pour Optimiser</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> 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,6 +6,7 @@
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 = [
{
@ -22,7 +23,7 @@ const containers = [
tare: '2,300 kg',
},
usage: 'Marchandises générales sèches',
icon: '📦',
icon: Package,
},
{
type: '40\' Standard (40\' DRY)',
@ -38,7 +39,7 @@ const containers = [
tare: '3,800 kg',
},
usage: 'Marchandises générales, cargo volumineux',
icon: '📦',
icon: Package,
},
{
type: '40\' High Cube (40\' HC)',
@ -54,7 +55,7 @@ const containers = [
tare: '4,020 kg',
},
usage: 'Cargo léger mais volumineux',
icon: '📦',
icon: Package,
},
{
type: 'Reefer (Réfrigéré)',
@ -70,7 +71,7 @@ const containers = [
temperature: '-30°C à +30°C',
},
usage: 'Produits périssables, pharmaceutiques',
icon: '❄️',
icon: Snowflake,
},
{
type: 'Open Top',
@ -86,7 +87,7 @@ const containers = [
tare: '2,400 kg / 4,100 kg',
},
usage: 'Cargo hors gabarit en hauteur, machinerie',
icon: '📭',
icon: PackageOpen,
},
{
type: 'Flat Rack',
@ -102,7 +103,7 @@ const containers = [
tare: '2,700 kg / 4,700 kg',
},
usage: 'Cargo très lourd ou surdimensionné',
icon: '🚛',
icon: Truck,
},
{
type: 'Tank Container',
@ -118,7 +119,7 @@ const containers = [
tare: '3,500 kg',
},
usage: 'Liquides, gaz, produits chimiques',
icon: '🛢️',
icon: Cylinder,
},
];
@ -148,7 +149,7 @@ export default function ConteneursPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">📦</span>
<Package className="w-10 h-10 text-blue-600" />
<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">
@ -189,7 +190,7 @@ export default function ConteneursPage() {
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{container.icon}</span>
<container.icon className="w-6 h-6 text-blue-600" />
<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">
@ -260,35 +261,35 @@ export default function ConteneursPage() {
<CardContent>
<div className="space-y-3">
<div className="flex items-start gap-3">
<span className="text-xl">📦</span>
<Package className="w-5 h-5 text-green-700 mt-0.5" />
<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">
<span className="text-xl"></span>
<Snowflake className="w-5 h-5 text-green-700 mt-0.5" />
<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">
<span className="text-xl">📭</span>
<PackageOpen className="w-5 h-5 text-green-700 mt-0.5" />
<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">
<span className="text-xl">🚛</span>
<Truck className="w-5 h-5 text-green-700 mt-0.5" />
<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">
<span className="text-xl">🛢</span>
<Cylinder className="w-5 h-5 text-green-700 mt-0.5" />
<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,6 +6,7 @@
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 = [
{
@ -18,7 +19,7 @@ const documents = [
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
],
importance: 'Critique',
icon: '📄',
icon: FileText,
},
{
name: 'Sea Waybill',
@ -29,7 +30,7 @@ const documents = [
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
],
importance: 'Important',
icon: '📋',
icon: ClipboardList,
},
{
name: 'Manifest',
@ -40,7 +41,7 @@ const documents = [
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
],
importance: 'Obligatoire',
icon: '📑',
icon: FileStack,
},
{
name: 'Packing List',
@ -51,7 +52,7 @@ const documents = [
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
],
importance: 'Important',
icon: '📦',
icon: Package,
},
{
name: 'Commercial Invoice',
@ -62,7 +63,7 @@ const documents = [
{ name: 'Définitive', desc: 'Document final de facturation' },
],
importance: 'Critique',
icon: '🧾',
icon: Receipt,
},
{
name: 'Certificate of Origin',
@ -74,7 +75,7 @@ const documents = [
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
],
importance: 'Selon destination',
icon: '🏭',
icon: Factory,
},
];
@ -120,7 +121,7 @@ export default function DocumentsTransportPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">📋</span>
<ClipboardList className="w-10 h-10 text-blue-600" />
<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">
@ -149,7 +150,7 @@ export default function DocumentsTransportPage() {
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{doc.icon}</span>
<doc.icon className="w-6 h-6 text-blue-600" />
<div>
<span className="text-lg">{doc.name}</span>
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>

View File

@ -6,6 +6,7 @@
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 = [
{
@ -97,7 +98,7 @@ export default function DouanesPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🛃</span>
<ShieldCheck className="w-10 h-10 text-blue-600" />
<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">
@ -134,7 +135,7 @@ export default function DouanesPage() {
{/* Régimes douaniers */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{regimesDouaniers.map((regime) => (
<Card key={regime.code} className="bg-white">
@ -156,7 +157,7 @@ export default function DouanesPage() {
{/* Documents requis */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -179,7 +180,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">💰 Droits et Taxes</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> 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>
@ -203,7 +204,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"> Points d&apos;Attention</h3>
<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>
<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,6 +6,7 @@
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 = [
{
@ -110,7 +111,7 @@ export default function IMDGPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<AlertTriangle className="w-10 h-10 text-orange-500" />
<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">
@ -123,7 +124,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"> Responsabilités de l&apos;Expéditeur</h3>
<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>
<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>
@ -136,7 +137,7 @@ export default function IMDGPage() {
{/* Classes */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
<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>
<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">
@ -171,7 +172,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">🔢 Numéro ONU (UN Number)</h3>
<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>
<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.
@ -195,7 +196,7 @@ export default function IMDGPage() {
{/* Packaging Groups */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d&apos;Emballage</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -219,7 +220,7 @@ export default function IMDGPage() {
{/* Documents */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -240,7 +241,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">🏷 Marquage et Étiquetage</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Tag className="w-5 h-5" /> 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>
@ -267,7 +268,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">🔀 Ségrégation</h3>
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><Shuffle className="w-5 h-5" /> 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 :
@ -296,7 +297,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">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> 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,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ScrollText, Ship, ArrowUpFromLine, ArrowDownToLine } from 'lucide-react';
const incoterms = [
{
@ -119,7 +120,7 @@ export default function IncotermsPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">📜</span>
<ScrollText className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -146,8 +147,11 @@ export default function IncotermsPage() {
{categories.map((category) => (
<div key={category} className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
<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>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{incoterms

View File

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

View File

@ -6,6 +6,7 @@
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 = [
{
@ -93,7 +94,7 @@ export default function LettreCreditPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">💳</span>
<CreditCard className="w-10 h-10 text-blue-600" />
<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">
@ -106,7 +107,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">🔄 Fonctionnement Simplifié</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> 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' },
@ -129,7 +130,7 @@ export default function LettreCreditPage() {
{/* Parties */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -149,7 +150,7 @@ export default function LettreCreditPage() {
{/* Types */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
<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>
<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' : ''}`}>
@ -172,7 +173,7 @@ export default function LettreCreditPage() {
{/* Documents */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -192,7 +193,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">📅 Dates Clés à Surveiller</h3>
<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>
<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>
@ -219,7 +220,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">💰 Coûts Typiques</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> 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>
@ -249,7 +250,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"> Erreurs Fréquentes (Réserves)</h3>
<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>
<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>
@ -264,7 +265,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">📜 Règles UCP 600</h3>
<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>
<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 :
@ -281,7 +282,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">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> 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,89 +6,112 @@
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';
const wikiTopics = [
interface WikiTopic {
title: string;
description: string;
icon: LucideIcon;
href: string;
tags: string[];
}
const wikiTopics: WikiTopic[] = [
{
title: 'Incoterms 2020',
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
icon: '📜',
icon: ScrollText,
href: '/dashboard/wiki/incoterms',
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
},
{
title: 'Documents de Transport',
description: 'Les documents essentiels pour le transport maritime',
icon: '📋',
icon: ClipboardList,
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: '📦',
icon: Package,
href: '/dashboard/wiki/conteneurs',
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
},
{
title: 'LCL vs FCL',
description: 'Différences entre groupage et conteneur complet',
icon: '⚖️',
icon: Scale,
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: '🛃',
icon: ShieldCheck,
href: '/dashboard/wiki/douanes',
tags: ['Déclaration', 'Tarifs', 'Régimes'],
},
{
title: 'Assurance Maritime',
description: 'Protection des marchandises en transit',
icon: '🛡️',
icon: Shield,
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: '🧮',
icon: Calculator,
href: '/dashboard/wiki/calcul-fret',
tags: ['CBM', 'THC', 'BAF', 'CAF'],
},
{
title: 'Ports et Routes Maritimes',
description: 'Les principales routes commerciales mondiales',
icon: '🌍',
icon: Globe,
href: '/dashboard/wiki/ports-routes',
tags: ['Hub', 'Détroits', 'Canaux'],
},
{
title: 'VGM (Verified Gross Mass)',
description: 'Obligation de pesée des conteneurs (SOLAS)',
icon: '⚓',
icon: Anchor,
href: '/dashboard/wiki/vgm',
tags: ['SOLAS', 'Pesée', 'Certification'],
},
{
title: 'Marchandises Dangereuses (IMDG)',
description: 'Transport de matières dangereuses par mer',
icon: '⚠️',
icon: AlertTriangle,
href: '/dashboard/wiki/imdg',
tags: ['Classes', 'Étiquetage', 'Sécurité'],
},
{
title: 'Lettre de Crédit (L/C)',
description: 'Instrument de paiement international sécurisé',
icon: '💳',
icon: CreditCard,
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: '⏱️',
icon: Timer,
href: '/dashboard/wiki/transit-time',
tags: ['Cut-off', 'Free time', 'Demurrage'],
},
@ -107,12 +130,16 @@ 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) => (
{wikiTopics.map((topic) => {
const IconComponent = topic.icon;
return (
<Link key={topic.href} href={topic.href} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
<CardHeader>
<div className="flex items-start justify-between">
<span className="text-4xl">{topic.icon}</span>
<div className="h-10 w-10 rounded-lg bg-blue-50 flex items-center justify-center">
<IconComponent className="h-5 w-5 text-blue-600" />
</div>
</div>
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
{topic.title}
@ -135,7 +162,8 @@ export default function WikiPage() {
</CardContent>
</Card>
</Link>
))}
);
})}
</div>
{/* Footer info */}

View File

@ -6,6 +6,7 @@
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 = [
{
@ -113,7 +114,7 @@ export default function PortsRoutesPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🌍</span>
<Globe className="w-10 h-10 text-blue-600" />
<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">
@ -125,7 +126,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">📊 Chiffres Clés du Maritime Mondial</h3>
<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>
<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>
@ -149,7 +150,7 @@ export default function PortsRoutesPage() {
{/* Major Routes */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳 Routes Commerciales Majeures</h2>
<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>
<div className="space-y-4">
{majorRoutes.map((route) => (
<Card key={route.name} className="bg-white">
@ -184,7 +185,7 @@ export default function PortsRoutesPage() {
{/* Strategic Passages */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Passages Stratégiques</h2>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{strategicPassages.map((passage) => (
<Card key={passage.name} className="bg-white">
@ -222,7 +223,7 @@ export default function PortsRoutesPage() {
{/* Top Ports */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -261,7 +262,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">🔄 Ports Hub vs Ports Régionaux</h3>
<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>
<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>
@ -286,7 +287,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">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> 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,6 +6,7 @@
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 = [
{
@ -132,7 +133,7 @@ export default function TransitTimePage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<Clock className="w-10 h-10 text-blue-600" />
<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">
@ -145,7 +146,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">📖 Termes Clés</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BookOpen className="w-5 h-5" /> 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>
@ -169,7 +170,7 @@ export default function TransitTimePage() {
{/* Timeline */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d&apos;une Expédition FCL</h2>
<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>
<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' : ''}`}>
@ -195,7 +196,7 @@ export default function TransitTimePage() {
{/* Transit Times */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -228,7 +229,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"> Free Time (Jours Gratuits)</h3>
<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>
<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.
@ -257,7 +258,7 @@ export default function TransitTimePage() {
{/* Late Fees */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
<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>
<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">
@ -283,7 +284,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"> Facteurs Impactant les Délais</h3>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
@ -312,7 +313,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">🔄 Roll-over (Report)</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> 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.
@ -336,7 +337,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">💡 Conseils pour Optimiser les Délais</h3>
<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>
<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,6 +6,7 @@
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 = [
{
@ -69,7 +70,7 @@ export default function VGMPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<Anchor className="w-10 h-10 text-blue-600" />
<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">
@ -85,19 +86,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">🛡 Sécurité</h4>
<h4 className="font-medium flex items-center gap-1"><Shield className="w-4 h-4" /> 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"> Stabilité du navire</h4>
<h4 className="font-medium flex items-center gap-1"><Scale className="w-4 h-4" /> 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">🏗 Équipements portuaires</h4>
<h4 className="font-medium flex items-center gap-1"><Construction className="w-4 h-4" /> É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">🚛 Transport terrestre</h4>
<h4 className="font-medium flex items-center gap-1"><Truck className="w-4 h-4" /> Transport terrestre</h4>
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
</div>
</div>
@ -106,7 +107,7 @@ export default function VGMPage() {
{/* VGM Components */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
@ -131,7 +132,7 @@ export default function VGMPage() {
{/* Methods */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
<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>
<div className="space-y-4">
{methodesPesee.map((method) => (
<Card key={method.method} className="bg-white">
@ -186,7 +187,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">👤 Responsabilités</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><User className="w-5 h-5" /> 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>
@ -213,7 +214,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">📏 Tolérances</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> 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 :
@ -234,7 +235,7 @@ export default function VGMPage() {
{/* Sanctions */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Sanctions par Région</h2>
<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>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -252,7 +253,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">💡 Bonnes Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> 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,6 +13,66 @@ 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('');
@ -20,17 +80,64 @@ 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) {
setError(err.message || 'Identifiants incorrects');
const { message, field } = getErrorMessage(err);
if (field === 'email') {
setFieldErrors({ email: message });
} else if (field === 'password') {
setFieldErrors({ password: message });
} else {
setError(message);
}
} finally {
setIsLoading(false);
}
@ -65,7 +172,20 @@ export default function LoginPage() {
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<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>
<p className="text-body-sm text-red-800">{error}</p>
</div>
)}
@ -74,38 +194,74 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email */}
<div>
<label htmlFor="email" className="label">
<label htmlFor="email" className={`label ${fieldErrors.email ? 'text-red-600' : ''}`}>
Adresse email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="input w-full"
onChange={handleEmailChange}
className={`input w-full ${
fieldErrors.email
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
: ''
}`}
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">
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}>
Mot de passe
</label>
<input
id="password"
type="password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="input w-full"
onChange={handlePasswordChange}
className={`input w-full ${
fieldErrors.password
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
: ''
}`}
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

@ -0,0 +1,221 @@
'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: 'Dashboard Analytics',
title: 'Tableau de bord',
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 Bookings',
title: 'Gestion des Réservations',
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: 'Track & Trace',
title: 'Suivi des expéditions',
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: 'Dashboard',
title: 'Tableau de bord',
description: 'Vue d\'ensemble de votre activité maritime',
link: '/dashboard',
},
{
icon: Package,
title: 'Mes Bookings',
title: 'Mes Réservations',
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: 'Track & Trace',
title: 'Suivi des expéditions',
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: 'API access', included: false },
{ text: 'Accès API', 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: 'API access', included: false },
{ text: 'Accès API', 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 Professional +', included: true },
{ text: 'API access complet', included: true },
{ text: 'Tout Professionnel +', included: true },
{ text: 'Accès API complet', included: true },
{ text: 'Intégrations personnalisées', included: true },
{ text: 'Account manager dédié', included: true },
{ text: 'Responsable de compte 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 Dashboard</span>
<span>Accéder au tableau de bord</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 booking en un clic',
description: 'Confirmez votre réservation en un clic',
icon: CheckCircle2,
},
{
step: '04',
title: 'Suivez',
description: 'Trackez votre envoi en temps réel',
description: 'Suivez 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 Dashboard</span>
<span>Accéder au tableau de bord</span>
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
) : (

View File

@ -1,265 +1,279 @@
/**
* Cookie Consent Banner
* GDPR Compliant
* GDPR Compliant - French version
*/
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
'use client';
interface CookiePreferences {
essential: boolean; // Always true (required for functionality)
functional: boolean;
analytics: boolean;
marketing: boolean;
}
import React, { useState } 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';
export default function CookieConsent() {
const [showBanner, setShowBanner] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>({
essential: true,
functional: true,
analytics: false,
marketing: false,
});
const {
preferences,
showBanner,
showSettings,
isLoading,
setShowBanner,
setShowSettings,
acceptAll,
acceptEssentialOnly,
savePreferences,
openPreferences,
} = useCookieConsent();
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);
}
}, []);
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>(preferences);
const acceptAll = () => {
const allAccepted: CookiePreferences = {
essential: true,
functional: true,
analytics: true,
marketing: true,
};
savePreferences(allAccepted);
// Sync local prefs when context changes
React.useEffect(() => {
setLocalPrefs(preferences);
}, [preferences]);
const handleSaveCustom = async () => {
await savePreferences(localPrefs);
};
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) {
// Don't render anything while loading
if (isLoading) {
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 */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-gray-200 shadow-2xl">
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<AnimatePresence mode="wait">
{!showSettings ? (
// Simple banner
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<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-1">
<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
<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
</Link>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<button
onClick={() => setShowSettings(true)}
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"
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"
>
Customize
<Settings className="w-4 h-4" />
Personnaliser
</button>
<button
onClick={acceptEssentialOnly}
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"
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"
>
Essential Only
Essentiel uniquement
</button>
<button
onClick={acceptAll}
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"
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"
>
Accept All
<Check className="w-4 h-4" />
Tout accepter
</button>
</div>
</div>
</motion.div>
) : (
// Detailed settings
<div>
<motion.div
key="settings"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Cookie Preferences</h3>
<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>
<button
onClick={() => setShowSettings(false)}
className="text-gray-400 hover:text-gray-600"
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Fermer les paramètres"
>
<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>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4 mb-6">
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
{/* Essential Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<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
<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
</span>
</div>
<p className="mt-1 text-sm text-gray-600">
Required for the website to function. Cannot be disabled.
Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés.
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={true}
disabled
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded"
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
/>
</div>
</div>
{/* Functional Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">Functional Cookies</h4>
<h4 className="text-sm font-semibold text-gray-900">
Cookies fonctionnels
</h4>
<p className="mt-1 text-sm text-gray-600">
Remember your preferences and settings (e.g., language, region).
Permettent de mémoriser vos préférences et paramètres (langue, gion).
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
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"
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"
/>
</div>
</div>
{/* Analytics Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">Analytics Cookies</h4>
<h4 className="text-sm font-semibold text-gray-900">
Cookies analytiques
</h4>
<p className="mt-1 text-sm text-gray-600">
Help us understand how visitors interact with our website (Google Analytics,
Sentry).
Nous aident à comprendre comment les visiteurs interagissent avec notre
site (Google Analytics, Sentry).
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
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"
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"
/>
</div>
</div>
{/* Marketing Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">Marketing Cookies</h4>
<h4 className="text-sm font-semibold text-gray-900">
Cookies marketing
</h4>
<p className="mt-1 text-sm text-gray-600">
Used to deliver personalized ads and measure campaign effectiveness.
Utilisés pour afficher des publicités personnalisées et mesurer
l'efficacité des campagnes.
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
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"
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"
/>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
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"
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"
>
Save Preferences
<Check className="w-4 h-4" />
Enregistrer mes préférences
</button>
<button
onClick={acceptAll}
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"
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"
>
Accept All
Tout accepter
</button>
</div>
<p className="mt-4 text-xs text-gray-500 text-center">
You can change your preferences at any time in your account settings or by clicking
the cookie icon in the footer.
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>
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -0,0 +1,244 @@
/**
* 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,6 +11,18 @@ 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);
@ -83,17 +95,17 @@ export default function NotificationDropdown() {
return colors[priority as keyof typeof colors] || colors.low;
};
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
BOOKING_CONFIRMED: '✅',
BOOKING_UPDATED: '🔄',
BOOKING_CANCELLED: '❌',
RATE_ALERT: '💰',
CARRIER_UPDATE: '🚢',
SYSTEM: '⚙️',
WARNING: '⚠️',
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,
};
return icons[type] || '📢';
return icons[type] || Megaphone;
};
const formatTime = (dateString: string) => {
@ -104,10 +116,10 @@ export default function NotificationDropdown() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
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`;
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();
};
@ -146,7 +158,7 @@ export default function NotificationDropdown() {
disabled={markAllAsReadMutation.isPending}
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
Mark all as read
Tout marquer comme lu
</button>
)}
</div>
@ -154,11 +166,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">Loading notifications...</div>
<div className="p-4 text-center text-sm text-gray-500">Chargement des notifications...</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<div className="text-4xl mb-2">🔔</div>
<p className="text-sm text-gray-500">No new notifications</p>
<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>
) : (
<div className="divide-y">
@ -172,8 +184,8 @@ export default function NotificationDropdown() {
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 text-2xl">
{getNotificationIcon(notification.type)}
<div className="flex-shrink-0">
{(() => { const Icon = getNotificationIcon(notification.type); return <Icon className="h-5 w-5 text-gray-500" />; })()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
@ -210,7 +222,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"
>
View all notifications
Voir toutes les 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 } from 'lucide-react';
import { X, Trash2, CheckCheck, Filter, Bell, Package, RefreshCw, XCircle, CheckCircle, Mail, Timer, FileText, Megaphone, User, Building2 } 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('Are you sure you want to delete this notification?')) {
if (confirm('Voulez-vous vraiment supprimer cette 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 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: '🏢',
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,
};
return icons[type.toLowerCase()] || '🔔';
return icons[type.toLowerCase()] || Bell;
};
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 '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',
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',
month: 'short',
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="Close panel"
aria-label="Fermer le panneau"
>
<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.charAt(0).toUpperCase() + filter.slice(1)}
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
</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>Mark all as read</span>
<span>Tout marquer comme lu</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">Loading notifications...</p>
<p className="text-sm text-gray-500">Chargement des notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-6xl mb-4">🔔</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No notifications</h3>
<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>
<p className="text-sm text-gray-500">
{selectedFilter === 'unread'
? "You're all caught up!"
: 'No notifications to display'}
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
</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 text-3xl">
{getNotificationIcon(notification.type)}
<div className="flex-shrink-0">
{(() => { const Icon = getNotificationIconComponent(notification.type); return <Icon className="h-6 w-6 text-gray-500" />; })()}
</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="Delete notification"
title="Supprimer la 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">
View details
Voir les tails
</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} of {totalPages}
Page {currentPage} sur {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"
>
Previous
Précédent
</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"
>
Next
Suivant
</button>
</div>
</div>

View File

@ -1,6 +1,7 @@
"use client";
import { MapContainer, TileLayer, Polyline, Marker } from "react-leaflet";
import { useEffect, useMemo } from "react";
import { MapContainer, TileLayer, Polyline, Marker, useMap } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
@ -19,22 +20,352 @@ 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={4}
zoom={2}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom={false}
>
@ -43,10 +374,25 @@ 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]} />
<Polyline positions={positions} pathOptions={{ color: "#2563eb", weight: 3, opacity: 0.7 }} />
{/* Maritime route polyline */}
<Polyline
positions={positions}
pathOptions={{
color: "#2563eb",
weight: 3,
opacity: 0.8,
dashArray: "10, 6",
}}
/>
</MapContainer>
</div>
);

View File

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

View File

@ -9,6 +9,8 @@
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('');
@ -110,10 +112,17 @@ 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">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
<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">
@ -335,9 +344,6 @@ 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,6 +9,8 @@
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
@ -27,7 +29,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
<CookieProvider>
{children}
<CookieConsent />
</CookieProvider>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@ -165,7 +165,12 @@ export async function apiRequest<T>(
});
// Handle 401 Unauthorized - token expired
if (response.status === 401 && !isRetry && !endpoint.includes('/auth/refresh')) {
// 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) {
// Check if we have a refresh token
const refreshToken = getRefreshToken();
if (!refreshToken) {

View File

@ -5,11 +5,38 @@
*/
import { get, post, patch } from './client';
import type {
SuccessResponse,
} from '@/types/api';
import type { SuccessResponse } from '@/types/api';
// TODO: These types should be moved to @/types/api.ts
/**
* 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
*/
export interface GdprDataExportResponse {
exportId: string;
status: 'PENDING' | 'COMPLETED' | 'FAILED';
@ -18,49 +45,50 @@ 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)
* POST /api/v1/gdpr/export
* Generates export job and sends download link via email
* GET /api/v1/gdpr/export
* Triggers download of JSON file
*/
export async function requestDataExport(): Promise<GdprDataExportResponse> {
return post<GdprDataExportResponse>('/api/v1/gdpr/export');
}
/**
* Download exported data
* GET /api/v1/gdpr/export/:exportId/download
* Returns blob (JSON file)
*/
export async function downloadDataExport(exportId: string): Promise<Blob> {
export async function requestDataExport(): Promise<Blob> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/${exportId}/download`,
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
},
}
);
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`);
throw new Error(`Export failed: ${response.statusText}`);
}
return response.blob();
}
/**
* Request data export as CSV
* GET /api/v1/gdpr/export/csv
*/
export async function requestDataExportCSV(): Promise<Blob> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
},
}
);
if (!response.ok) {
throw new Error(`Export failed: ${response.statusText}`);
}
return response.blob();
@ -68,35 +96,53 @@ export async function downloadDataExport(exportId: string): Promise<Blob> {
/**
* Request account deletion (GDPR right to be forgotten)
* POST /api/v1/gdpr/delete-account
* Initiates 30-day account deletion process
* DELETE /api/v1/gdpr/delete-account
* Initiates account deletion process
*/
export async function requestAccountDeletion(): Promise<SuccessResponse> {
return post<SuccessResponse>('/api/v1/gdpr/delete-account');
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 }),
}
);
/**
* Cancel pending account deletion
* POST /api/v1/gdpr/cancel-deletion
*/
export async function cancelAccountDeletion(): Promise<SuccessResponse> {
return post<SuccessResponse>('/api/v1/gdpr/cancel-deletion');
if (!response.ok) {
throw new Error(`Deletion failed: ${response.statusText}`);
}
}
/**
* Get user consent preferences
* GET /api/v1/gdpr/consent
*/
export async function getConsentPreferences(): Promise<GdprConsentResponse> {
return get<GdprConsentResponse>('/api/v1/gdpr/consent');
export async function getConsentPreferences(): Promise<ConsentResponse | null> {
return get<ConsentResponse | null>('/api/v1/gdpr/consent');
}
/**
* Update consent preferences
* PATCH /api/v1/gdpr/consent
* POST /api/v1/gdpr/consent
*/
export async function updateConsentPreferences(
data: UpdateGdprConsentRequest
): Promise<GdprConsentResponse> {
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data);
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 });
}

View File

@ -107,11 +107,14 @@ export {
// GDPR (6 endpoints)
export {
requestDataExport,
downloadDataExport,
requestDataExportCSV,
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 { post } from './client';
import { get, post } from './client';
import type {
RateSearchRequest,
RateSearchResponse,
@ -14,6 +14,37 @@ 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
@ -58,3 +89,29 @@ 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

@ -0,0 +1,228 @@
/**
* 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;
}