This commit is contained in:
David 2026-02-05 11:53:22 +01:00
parent 1d279a0e12
commit fd1f57dd1d
12 changed files with 927 additions and 61 deletions

View File

@ -1,8 +1,12 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import {
CarrierDocumentsResponseDto,
VerifyDocumentAccessDto,
DocumentAccessRequirementsDto,
} from '../dto/carrier-documents.dto';
/** /**
* CSV Booking Actions Controller (Public Routes) * CSV Booking Actions Controller (Public Routes)
@ -91,16 +95,71 @@ export class CsvBookingActionsController {
} }
/** /**
* Get booking documents for carrier (PUBLIC - token-based) * 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 * GET /api/v1/csv-booking-actions/documents/:token
*/ */
@Public() @Public()
@Get('documents/:token') @Get('documents/:token')
@ApiOperation({ @ApiOperation({
summary: 'Get booking documents (public)', summary: 'Get booking documents (public) - Legacy',
description: description:
'Public endpoint for carriers to access booking documents after acceptance. Returns booking summary and documents with signed download URLs.', 'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
}) })
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({ @ApiResponse({
@ -110,6 +169,7 @@ export class CsvBookingActionsController {
}) })
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' }) @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> { async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
return this.csvBookingService.getDocumentsForCarrier(token); return this.csvBookingService.getDocumentsForCarrier(token);
} }

View File

@ -1,4 +1,29 @@
import { ApiProperty } from '@nestjs/swagger'; 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 * Booking Summary DTO for Carrier Documents Page
@ -7,6 +32,9 @@ export class BookingSummaryDto {
@ApiProperty({ description: 'Booking unique ID' }) @ApiProperty({ description: 'Booking unique ID' })
id: string; id: string;
@ApiPropertyOptional({ description: 'Human-readable booking number' })
bookingNumber?: string;
@ApiProperty({ description: 'Carrier/Company name' }) @ApiProperty({ description: 'Carrier/Company name' })
carrierName: string; carrierName: string;

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 { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity'; import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
import { PortCode } from '@domain/value-objects/port-code.vo'; import { PortCode } from '@domain/value-objects/port-code.vo';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
@ -57,6 +65,27 @@ export class CsvBookingService {
private readonly storageAdapter: StoragePort 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 * Create a new CSV booking request
*/ */
@ -73,9 +102,14 @@ export class CsvBookingService {
throw new BadRequestException('At least one document is required'); throw new BadRequestException('At least one document is required');
} }
// Generate unique confirmation token // Generate unique confirmation token and booking number
const confirmationToken = uuidv4(); const confirmationToken = uuidv4();
const bookingId = 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 // Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId); const documents = await this.uploadDocuments(files, bookingId);
@ -107,13 +141,26 @@ export class CsvBookingService {
// Save to database // Save to database
const savedBooking = await this.csvBookingRepository.create(booking); 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 // Send email to carrier and WAIT for confirmation
// The button waits for the email to be sent before responding // The button waits for the email to be sent before responding
try { try {
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId, bookingId,
bookingNumber,
documentPassword,
origin: dto.origin, origin: dto.origin,
destination: dto.destination, destination: dto.destination,
volumeCBM: dto.volumeCBM, volumeCBM: dto.volumeCBM,
@ -203,21 +250,45 @@ export class CsvBookingService {
} }
/** /**
* Get booking documents for carrier (public endpoint) * Verify password and get booking documents for carrier (public endpoint)
* Only accessible for ACCEPTED bookings * Only accessible for ACCEPTED bookings with correct password
*/ */
async getDocumentsForCarrier(token: string): Promise<CarrierDocumentsResponseDto> { async getDocumentsForCarrier(
token: string,
password?: string
): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`); this.logger.log(`Getting documents for carrier with token: ${token}`);
const booking = await this.csvBookingRepository.findByToken(token); // Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!booking) { if (!ormBooking) {
throw new NotFoundException('Réservation introuvable'); throw new NotFoundException('Réservation introuvable');
} }
// Only allow access for ACCEPTED bookings // Only allow access for ACCEPTED bookings
if (booking.status !== CsvBookingStatus.ACCEPTED) { if (ormBooking.status !== 'ACCEPTED') {
throw new BadRequestException('Cette réservation n\'a pas encore été acceptée'); 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 // Generate signed URLs for all documents
@ -240,6 +311,7 @@ export class CsvBookingService {
return { return {
booking: { booking: {
id: booking.id, id: booking.id,
bookingNumber: ormBooking.bookingNumber || undefined,
carrierName: booking.carrierName, carrierName: booking.carrierName,
origin: booking.origin.getValue(), origin: booking.origin.getValue(),
destination: booking.destination.getValue(), destination: booking.destination.getValue(),
@ -257,6 +329,27 @@ export class CsvBookingService {
}; };
} }
/**
* 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 * Generate signed URL for a document file path
*/ */
@ -292,6 +385,11 @@ export class CsvBookingService {
throw new NotFoundException('Booking not found'); 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) // Accept the booking (domain logic validates status)
booking.accept(); booking.accept();
@ -299,11 +397,19 @@ export class CsvBookingService {
const updatedBooking = await this.csvBookingRepository.update(booking); const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`); 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 // Send document access email to carrier
try { try {
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, { await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
carrierName: booking.carrierName, carrierName: booking.carrierName,
bookingId: booking.id, bookingId: booking.id,
bookingNumber: bookingNumber || undefined,
documentPassword: documentPassword,
origin: booking.origin.getValue(), origin: booking.origin.getValue(),
destination: booking.destination.getValue(), destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM, volumeCBM: booking.volumeCBM,

View File

@ -85,6 +85,8 @@ export interface EmailPort {
carrierEmail: string, carrierEmail: string,
bookingDetails: { bookingDetails: {
bookingId: string; bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
@ -129,6 +131,8 @@ export interface EmailPort {
data: { data: {
carrierName: string; carrierName: string;
bookingId: string; bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;

View File

@ -239,6 +239,8 @@ export class EmailAdapter implements EmailPort {
carrierEmail: string, carrierEmail: string,
bookingData: { bookingData: {
bookingId: string; bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
@ -270,7 +272,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: carrierEmail, 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, html,
}); });
@ -436,6 +438,8 @@ export class EmailAdapter implements EmailPort {
data: { data: {
carrierName: string; carrierName: string;
bookingId: string; bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
@ -447,6 +451,20 @@ export class EmailAdapter implements EmailPort {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; 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 = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -474,6 +492,7 @@ export class EmailAdapter implements EmailPort {
<div class="header"> <div class="header">
<h1>Documents disponibles</h1> <h1>Documents disponibles</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p> <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>
<div class="content"> <div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p> <p>Bonjour <strong>${data.carrierName}</strong>,</p>
@ -498,12 +517,14 @@ export class EmailAdapter implements EmailPort {
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span> <span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
</div> </div>
${passwordSection}
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a> <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> <p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p> <p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p> <p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div> </div>
</div> </div>
@ -513,7 +534,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: carrierEmail, to: carrierEmail,
subject: `Documents disponibles - Reservation ${data.origin}${data.destination}`, subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin}${data.destination}`,
html, html,
}); });

View File

@ -261,6 +261,8 @@ export class EmailTemplates {
*/ */
async renderCsvBookingRequest(data: { async renderCsvBookingRequest(data: {
bookingId: string; bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
@ -481,6 +483,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. 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> </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 --> <!-- Booking Details -->
<div class="section-title">📋 Détails du transport</div> <div class="section-title">📋 Détails du transport</div>
<table class="details-table"> <table class="details-table">

View File

@ -96,6 +96,13 @@ export class CsvBookingOrmEntity {
@Index() @Index()
confirmationToken: string; 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' }) @Column({ name: 'requested_at', type: 'timestamp with time zone' })
@Index() @Index()
requestedAt: Date; requestedAt: Date;

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

@ -12,6 +12,9 @@ import {
Clock, Clock,
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
Lock,
Eye,
EyeOff,
} from 'lucide-react'; } from 'lucide-react';
interface Document { interface Document {
@ -25,6 +28,7 @@ interface Document {
interface BookingSummary { interface BookingSummary {
id: string; id: string;
bookingNumber?: string;
carrierName: string; carrierName: string;
origin: string; origin: string;
destination: string; destination: string;
@ -44,6 +48,12 @@ interface CarrierDocumentsData {
documents: Document[]; documents: Document[];
} }
interface AccessRequirements {
requiresPassword: boolean;
bookingNumber?: string;
status: string;
}
const documentTypeLabels: Record<string, string> = { const documentTypeLabels: Record<string, string> = {
BILL_OF_LADING: 'Connaissement', BILL_OF_LADING: 'Connaissement',
PACKING_LIST: 'Liste de colisage', PACKING_LIST: 'Liste de colisage',
@ -75,9 +85,18 @@ export default function CarrierDocumentsPage() {
const [data, setData] = useState<CarrierDocumentsData | null>(null); const [data, setData] = useState<CarrierDocumentsData | null>(null);
const [downloading, setDownloading] = useState<string | null>(null); const [downloading, setDownloading] = useState<string | null>(null);
const hasCalledApi = useRef(false); // 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 fetchDocuments = async () => { const hasCalledApi = useRef(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
// Check access requirements first
const checkRequirements = async () => {
if (!token) { if (!token) {
setError('Lien invalide'); setError('Lien invalide');
setLoading(false); setLoading(false);
@ -85,7 +104,61 @@ export default function CarrierDocumentsPage() {
} }
try { try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; 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}`, { const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -103,10 +176,20 @@ export default function CarrierDocumentsPage() {
const errorMessage = errorData.message || 'Erreur lors du chargement des documents'; const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
if (errorMessage.includes('pas encore été acceptée') || errorMessage.includes('not accepted')) { if (
throw new Error('Cette réservation n\'a pas encore été acceptée. Les documents seront disponibles après l\'acceptation.'); 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')) { } else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.'); throw new Error('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); throw new Error(errorMessage);
@ -122,12 +205,68 @@ export default function CarrierDocumentsPage() {
} }
}; };
// 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(() => { useEffect(() => {
if (hasCalledApi.current) return; if (hasCalledApi.current) return;
hasCalledApi.current = true; hasCalledApi.current = true;
fetchDocuments(); checkRequirements();
}, [token]); }, [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) => { const handleDownload = async (doc: Document) => {
setDownloading(doc.id); setDownloading(doc.id);
@ -146,24 +285,28 @@ export default function CarrierDocumentsPage() {
const handleRefresh = () => { const handleRefresh = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setData(null);
setRequirements(null);
setPassword('');
setPasswordError(null);
hasCalledApi.current = false; hasCalledApi.current = false;
fetchDocuments(); checkRequirements();
}; };
// Loading state
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-sky-50"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-sky-50">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-sky-600 mx-auto mb-4 animate-spin" /> <Loader2 className="w-16 h-16 text-sky-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
Chargement des documents...
</h1>
<p className="text-gray-600">Veuillez patienter</p> <p className="text-gray-600">Veuillez patienter</p>
</div> </div>
</div> </div>
); );
} }
// Error state
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
@ -182,6 +325,91 @@ export default function CarrierDocumentsPage() {
); );
} }
// Password form state
if (requirements?.requiresPassword && !data) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-sky-50 to-cyan-50 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-sky-100 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-sky-600" />
</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-sky-500 focus:border-sky-500 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-sky-600 text-white rounded-lg hover:bg-sky-700 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; if (!data) return null;
const { booking, documents } = data; const { booking, documents } = data;
@ -213,6 +441,11 @@ export default function CarrierDocumentsPage() {
<ArrowRight className="w-6 h-6" /> <ArrowRight className="w-6 h-6" />
<span className="text-2xl font-bold">{booking.destination}</span> <span className="text-2xl font-bold">{booking.destination}</span>
</div> </div>
{booking.bookingNumber && (
<p className="text-center text-sky-100 text-sm mt-1">
N° {booking.bookingNumber}
</p>
)}
</div> </div>
<div className="p-6"> <div className="p-6">
@ -241,10 +474,14 @@ export default function CarrierDocumentsPage() {
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm"> <div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500"> <span className="text-gray-500">
Transporteur: <span className="text-gray-900 font-medium">{booking.carrierName}</span> Transporteur:{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span> </span>
<span className="text-gray-500"> <span className="text-gray-500">
Ref: <span className="font-mono text-gray-900">{booking.id.substring(0, 8).toUpperCase()}</span> Ref:{' '}
<span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
</span> </span>
</div> </div>
</div> </div>
@ -263,11 +500,13 @@ export default function CarrierDocumentsPage() {
<div className="p-8 text-center"> <div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" /> <AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">Aucun document disponible pour le moment.</p> <p className="text-gray-600">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> <p className="text-gray-500 text-sm mt-1">
Les documents apparaîtront ici une fois ajoutés.
</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{documents.map((doc) => ( {documents.map(doc => (
<div <div
key={doc.id} key={doc.id}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors" className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
@ -280,9 +519,7 @@ export default function CarrierDocumentsPage() {
<span className="text-xs px-2 py-0.5 bg-sky-100 text-sky-700 rounded-full"> <span className="text-xs px-2 py-0.5 bg-sky-100 text-sky-700 rounded-full">
{documentTypeLabels[doc.type] || doc.type} {documentTypeLabels[doc.type] || doc.type}
</span> </span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
{formatFileSize(doc.size)}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -259,8 +259,10 @@ export default function UserDocumentsPage() {
} }
}; };
// Get unique bookings for add document modal // Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING'); const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
);
const handleAddDocumentClick = () => { const handleAddDocumentClick = () => {
setShowAddModal(true); setShowAddModal(true);
@ -435,7 +437,7 @@ export default function UserDocumentsPage() {
/> />
<button <button
onClick={handleAddDocumentClick} 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" 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"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -813,7 +815,7 @@ export default function UserDocumentsPage() {
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Sélectionner une réservation (en attente) Sélectionner une réservation
</label> </label>
<select <select
value={selectedBookingId || ''} value={selectedBookingId || ''}
@ -821,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" className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="">-- Choisir une réservation --</option> <option value="">-- Choisir une réservation --</option>
{bookingsWithPendingStatus.map(booking => ( {bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}> <option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} {getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
</option> </option>
))} ))}
</select> </select>

View File

@ -229,16 +229,6 @@ export default function AdvancedSearchPage() {
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2> <h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
{/* Info banner about available routes */}
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
<p className="text-sm text-blue-800">
Seuls les ports ayant des tarifs disponibles dans notre système sont proposés.
{originsData?.total && (
<span className="font-medium"> ({originsData.total} ports d'origine disponibles)</span>
)}
</p>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* Origin Port with Autocomplete - Limited to CSV routes */} {/* Origin Port with Autocomplete - Limited to CSV routes */}
<div className="relative"> <div className="relative">

View File

@ -1,6 +1,7 @@
"use client"; "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 "leaflet/dist/leaflet.css";
import L from "leaflet"; import L from "leaflet";
@ -19,22 +20,352 @@ const DefaultIcon = L.icon({
}); });
L.Marker.prototype.options.icon = DefaultIcon; 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) { 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 = { const center = {
lat: (portA.lat + portB.lat) / 2, lat: (portA.lat + portB.lat) / 2,
lng: (portA.lng + portB.lng) / 2, lng: (portA.lng + portB.lng) / 2,
}; };
const positions: [number, number][] = [
[portA.lat, portA.lng],
[portB.lat, portB.lng],
];
return ( return (
<div style={{ height }}> <div style={{ height }}>
<MapContainer <MapContainer
center={[center.lat, center.lng]} center={[center.lat, center.lng]}
zoom={4} zoom={2}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
scrollWheelZoom={false} 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' 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]} /> <Marker position={[portA.lat, portA.lng]} />
{/* Destination marker */}
<Marker position={[portB.lat, portB.lng]} /> <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> </MapContainer>
</div> </div>
); );