fix
This commit is contained in:
parent
1d279a0e12
commit
fd1f57dd1d
@ -1,8 +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 } from '../dto/carrier-documents.dto';
|
||||
import {
|
||||
CarrierDocumentsResponseDto,
|
||||
VerifyDocumentAccessDto,
|
||||
DocumentAccessRequirementsDto,
|
||||
} from '../dto/carrier-documents.dto';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@Public()
|
||||
@Get('documents/:token')
|
||||
@ApiOperation({
|
||||
summary: 'Get booking documents (public)',
|
||||
summary: 'Get booking documents (public) - Legacy',
|
||||
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)' })
|
||||
@ApiResponse({
|
||||
@ -110,6 +169,7 @@ export class CsvBookingActionsController {
|
||||
})
|
||||
@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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -7,6 +32,9 @@ 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;
|
||||
|
||||
|
||||
@ -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';
|
||||
@ -57,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
|
||||
*/
|
||||
@ -73,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);
|
||||
@ -107,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,
|
||||
@ -203,21 +250,45 @@ export class CsvBookingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking documents for carrier (public endpoint)
|
||||
* Only accessible for ACCEPTED bookings
|
||||
* Verify password and get booking documents for carrier (public endpoint)
|
||||
* 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}`);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Only allow access for ACCEPTED bookings
|
||||
if (booking.status !== CsvBookingStatus.ACCEPTED) {
|
||||
throw new BadRequestException('Cette réservation n\'a pas encore été acceptée');
|
||||
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
|
||||
@ -240,6 +311,7 @@ export class CsvBookingService {
|
||||
return {
|
||||
booking: {
|
||||
id: booking.id,
|
||||
bookingNumber: ormBooking.bookingNumber || undefined,
|
||||
carrierName: booking.carrierName,
|
||||
origin: booking.origin.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
|
||||
*/
|
||||
@ -292,6 +385,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();
|
||||
|
||||
@ -299,11 +397,19 @@ 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,
|
||||
|
||||
@ -85,6 +85,8 @@ export interface EmailPort {
|
||||
carrierEmail: string,
|
||||
bookingDetails: {
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
@ -129,6 +131,8 @@ export interface EmailPort {
|
||||
data: {
|
||||
carrierName: string;
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
|
||||
@ -239,6 +239,8 @@ export class EmailAdapter implements EmailPort {
|
||||
carrierEmail: string,
|
||||
bookingData: {
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
@ -270,7 +272,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,
|
||||
});
|
||||
|
||||
@ -436,6 +438,8 @@ export class EmailAdapter implements EmailPort {
|
||||
data: {
|
||||
carrierName: string;
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
@ -447,6 +451,20 @@ export class EmailAdapter implements EmailPort {
|
||||
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>
|
||||
@ -474,6 +492,7 @@ export class EmailAdapter implements EmailPort {
|
||||
<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>
|
||||
@ -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>
|
||||
</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.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>
|
||||
</div>
|
||||
</div>
|
||||
@ -513,7 +534,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
subject: `Documents disponibles - Reservation ${data.origin} → ${data.destination}`,
|
||||
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`,
|
||||
html,
|
||||
});
|
||||
|
||||
|
||||
@ -261,6 +261,8 @@ export class EmailTemplates {
|
||||
*/
|
||||
async renderCsvBookingRequest(data: {
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
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.
|
||||
</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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,9 @@ import {
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Document {
|
||||
@ -25,6 +28,7 @@ interface Document {
|
||||
|
||||
interface BookingSummary {
|
||||
id: string;
|
||||
bookingNumber?: string;
|
||||
carrierName: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
@ -44,6 +48,12 @@ interface CarrierDocumentsData {
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
interface AccessRequirements {
|
||||
requiresPassword: boolean;
|
||||
bookingNumber?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const documentTypeLabels: Record<string, string> = {
|
||||
BILL_OF_LADING: 'Connaissement',
|
||||
PACKING_LIST: 'Liste de colisage',
|
||||
@ -75,9 +85,18 @@ export default function CarrierDocumentsPage() {
|
||||
const [data, setData] = useState<CarrierDocumentsData | 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) {
|
||||
setError('Lien invalide');
|
||||
setLoading(false);
|
||||
@ -85,7 +104,61 @@ export default function CarrierDocumentsPage() {
|
||||
}
|
||||
|
||||
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}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@ -103,10 +176,20 @@ export default function CarrierDocumentsPage() {
|
||||
|
||||
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.');
|
||||
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);
|
||||
@ -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(() => {
|
||||
if (hasCalledApi.current) return;
|
||||
hasCalledApi.current = true;
|
||||
fetchDocuments();
|
||||
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);
|
||||
|
||||
@ -146,24 +285,28 @@ export default function CarrierDocumentsPage() {
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setRequirements(null);
|
||||
setPassword('');
|
||||
setPasswordError(null);
|
||||
hasCalledApi.current = false;
|
||||
fetchDocuments();
|
||||
checkRequirements();
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
Chargement des documents...
|
||||
</h1>
|
||||
<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-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>Où trouver le mot de passe ?</strong>
|
||||
<br />
|
||||
Le mot de passe vous a été 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;
|
||||
@ -213,6 +441,11 @@ export default function CarrierDocumentsPage() {
|
||||
<ArrowRight className="w-6 h-6" />
|
||||
<span className="text-2xl font-bold">{booking.destination}</span>
|
||||
</div>
|
||||
{booking.bookingNumber && (
|
||||
<p className="text-center text-sky-100 text-sm mt-1">
|
||||
N° {booking.bookingNumber}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
@ -263,11 +500,13 @@ export default function CarrierDocumentsPage() {
|
||||
<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>
|
||||
<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) => (
|
||||
{documents.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
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">
|
||||
{documentTypeLabels[doc.type] || doc.type}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatFileSize(doc.size)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -259,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);
|
||||
@ -435,7 +437,7 @@ export default function UserDocumentsPage() {
|
||||
/>
|
||||
<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">
|
||||
@ -813,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 || ''}
|
||||
@ -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"
|
||||
>
|
||||
<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>
|
||||
|
||||
@ -229,16 +229,6 @@ export default function AdvancedSearchPage() {
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
||||
<div className="relative">
|
||||
|
||||
@ -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='© <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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user