xpeditis2.0/apps/frontend/app/dashboard/track-trace/page.tsx
2026-02-03 16:08:00 +01:00

639 lines
24 KiB
TypeScript

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