Compare commits

...

8 Commits

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

1095
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,13 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common'; import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
Inject,
UnauthorizedException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { 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';
@ -21,6 +29,7 @@ import {
CsvBookingListResponseDto, CsvBookingListResponseDto,
CsvBookingStatsDto, CsvBookingStatsDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
/** /**
* CSV Booking Document (simple class for domain) * CSV Booking Document (simple class for domain)
@ -56,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
*/ */
@ -72,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);
@ -106,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,
@ -128,6 +176,7 @@ export class CsvBookingService {
fileName: doc.fileName, fileName: doc.fileName,
})), })),
confirmationToken, confirmationToken,
notes: dto.notes,
}); });
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
@ -201,6 +250,130 @@ export class CsvBookingService {
return this.toResponseDto(booking); return this.toResponseDto(booking);
} }
/**
* Verify password and get booking documents for carrier (public endpoint)
* Only accessible for ACCEPTED bookings with correct password
*/
async getDocumentsForCarrier(
token: string,
password?: string
): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`);
// Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
// Only allow access for ACCEPTED bookings
if (ormBooking.status !== 'ACCEPTED') {
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
}
// Check if password protection is enabled for this booking
if (ormBooking.passwordHash) {
if (!password) {
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
}
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Mot de passe incorrect');
}
}
// Get domain booking for business logic
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Réservation introuvable');
}
// Generate signed URLs for all documents
const documentsWithUrls = await Promise.all(
booking.documents.map(async doc => {
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
return {
id: doc.id,
type: doc.type,
fileName: doc.fileName,
mimeType: doc.mimeType,
size: doc.size,
downloadUrl: signedUrl,
};
})
);
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
booking: {
id: booking.id,
bookingNumber: ormBooking.bookingNumber || undefined,
carrierName: booking.carrierName,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
routeDescription: booking.getRouteDescription(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
price: booking.getPriceInCurrency(primaryCurrency),
currency: primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
acceptedAt: booking.respondedAt!,
},
documents: documentsWithUrls,
};
}
/**
* Check if a booking requires password for document access
*/
async checkDocumentAccessRequirements(
token: string
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
return {
requiresPassword: !!ormBooking.passwordHash,
bookingNumber: ormBooking.bookingNumber || undefined,
status: ormBooking.status,
};
}
/**
* Generate signed URL for a document file path
*/
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
const bucket = 'xpeditis-documents';
// Extract key from the file path
let key = filePath;
if (filePath.includes('xpeditis-documents/')) {
key = filePath.split('xpeditis-documents/')[1];
} else if (filePath.startsWith('http')) {
const url = new URL(filePath);
key = url.pathname.replace(/^\//, '');
if (key.startsWith('xpeditis-documents/')) {
key = key.replace('xpeditis-documents/', '');
}
}
// Generate signed URL with 1 hour expiration
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
return signedUrl;
}
/** /**
* Accept a booking request * Accept a booking request
*/ */
@ -213,6 +386,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();
@ -220,6 +398,31 @@ 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
try {
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
bookingNumber: bookingNumber || undefined,
documentPassword: documentPassword,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
documentCount: booking.documents.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
}
// Create notification for user // Create notification for user
try { try {
const notification = Notification.create({ const notification = Notification.create({
@ -475,9 +678,9 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Verify booking is still pending // Allow adding documents to PENDING or ACCEPTED bookings
if (booking.status !== CsvBookingStatus.PENDING) { if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
throw new BadRequestException('Cannot add documents to a booking that is not pending'); throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
} }
// Upload new documents // Upload new documents
@ -506,6 +709,24 @@ export class CsvBookingService {
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`); this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
// If booking is ACCEPTED, notify carrier about new documents
if (booking.status === CsvBookingStatus.ACCEPTED) {
try {
await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
newDocumentsCount: newDocuments.length,
totalDocumentsCount: updatedDocuments.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack);
}
}
return { return {
success: true, success: true,
message: 'Documents added successfully', message: 'Documents added successfully',
@ -701,6 +922,7 @@ export class CsvBookingService {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber,
userId: booking.userId, userId: booking.userId,
organizationId: booking.organizationId, organizationId: booking.organizationId,
carrierName: booking.carrierName, carrierName: booking.carrierName,

View File

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

View File

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

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

View File

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

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

View File

@ -261,6 +261,8 @@ export class EmailTemplates {
*/ */
async renderCsvBookingRequest(data: { async renderCsvBookingRequest(data: {
bookingId: string; bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
@ -275,6 +277,7 @@ export class EmailTemplates {
type: string; type: string;
fileName: string; fileName: string;
}>; }>;
notes?: string;
acceptUrl: string; acceptUrl: string;
rejectUrl: string; rejectUrl: string;
}): Promise<string> { }): Promise<string> {
@ -481,6 +484,21 @@ export class EmailTemplates {
Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande. 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">
@ -540,6 +558,14 @@ export class EmailTemplates {
</ul> </ul>
</div> </div>
{{#if notes}}
<!-- Notes -->
<div style="background-color: #f0f9ff; border-left: 4px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 5px 0; font-weight: 700; color: #045a8d; font-size: 14px;">📝 Notes du client</p>
<p style="margin: 0; font-size: 14px; color: #333; line-height: 1.6;">{{notes}}</p>
</div>
{{/if}}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<p>Veuillez confirmer votre décision :</p> <p>Veuillez confirmer votre décision :</p>

View File

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

View File

@ -96,6 +96,13 @@ export class CsvBookingOrmEntity {
@Index() @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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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