xpeditis2.0/apps/frontend/app/[locale]/dashboard/track-trace/page.tsx
2026-08-13 12:26:46 +02:00

545 lines
20 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import {
Anchor,
ClipboardList,
Clock,
ExternalLink,
FileText,
Globe,
History,
Maximize2,
Minimize2,
Package,
Search,
Ship,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Callout } from '@/components/ui/callout';
import { FormField } from '@/components/ui/form-field';
import { EmptyState } from '@/components/ui/states';
import { PageHeader } from '@/components/ui/PageHeader';
import { cn } from '@/lib/utils';
interface SearchHistoryItem {
id: string;
trackingNumber: string;
carrierId: string;
carrierName: string;
timestamp: Date;
}
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
/**
* Les couleurs `color` ci-dessous sont les identites visuelles des compagnies
* maritimes. Ce sont des marques tierces : elles ne relevent pas de la charte
* Xpeditis et sont reproduites telles quelles, a l'identique de l'original.
*/
const carriers = [
{
id: 'maersk',
name: 'Maersk',
color: '#00243D',
textColor: 'text-white',
trackingUrl: 'https://www.maersk.com/tracking/',
placeholder: 'Ex: MSKU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'msc',
name: 'MSC',
color: '#002B5C',
textColor: 'text-white',
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
placeholder: 'Ex: MSCU1234567',
descKey: 'containerBlOrBooking' as CarrierDescKey,
},
{
id: 'cma-cgm',
name: 'CMA CGM',
color: '#E30613',
textColor: 'text-white',
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
placeholder: 'Ex: CMAU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'hapag-lloyd',
name: 'Hapag-Lloyd',
color: '#FF6600',
textColor: 'text-white',
trackingUrl:
'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
placeholder: 'Ex: HLCU1234567',
descKey: 'containerOnly' as CarrierDescKey,
},
{
id: 'cosco',
name: 'COSCO',
color: '#003A70',
textColor: 'text-white',
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
placeholder: 'Ex: COSU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'one',
name: 'ONE',
color: '#FF00FF',
textColor: 'text-white',
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
placeholder: 'Ex: ONEU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'evergreen',
name: 'Evergreen',
color: '#006633',
textColor: 'text-white',
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
placeholder: 'Ex: EGHU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'yangming',
name: 'Yang Ming',
color: '#FFD700',
textColor: 'text-brand-navy',
trackingUrl:
'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
descKey: 'containerOnly' as CarrierDescKey,
},
{
id: 'zim',
name: 'ZIM',
color: '#1E3A8A',
textColor: 'text-white',
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
placeholder: 'Ex: ZIMU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
{
id: 'hmm',
name: 'HMM',
color: '#E65100',
textColor: 'text-white',
trackingUrl:
'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
placeholder: 'Ex: HDMU1234567',
descKey: 'containerOrBl' as CarrierDescKey,
},
];
const HISTORY_KEY = 'xpeditis_track_history';
export default function TrackTracePage() {
const t = useTranslations('dashboard.trackTrace');
const tCommon = useTranslations('common');
const locale = useLocale();
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);
useEffect(() => {
const saved = localStorage.getItem(HISTORY_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
setSearchHistory(parsed.map((item: any) => ({ ...item, timestamp: new Date(item.timestamp) })));
} catch (e) {
console.error('Failed to parse search history:', e);
}
}, []);
// Le plein ecran de la carte doit pouvoir se quitter au clavier.
useEffect(() => {
if (!isMapFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMapFullscreen(false);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isMapFullscreen]);
const saveHistory = (history: SearchHistoryItem[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
setSearchHistory(history);
};
const handleTrack = () => {
if (!trackingNumber.trim()) return setError(t('errors.noTrackingNumber'));
if (!selectedCarrier) return setError(t('errors.noCarrier'));
setError('');
const carrier = carriers.find(c => c.id === selectedCarrier);
if (!carrier) return;
const entry: SearchHistoryItem = {
id: Date.now().toString(),
trackingNumber: trackingNumber.trim(),
carrierId: carrier.id,
carrierName: carrier.name,
timestamp: new Date(),
};
saveHistory(
[
entry,
...searchHistory.filter(
h => !(h.trackingNumber === entry.trackingNumber && h.carrierId === entry.carrierId)
),
].slice(0, 10)
);
window.open(
carrier.trackingUrl + encodeURIComponent(trackingNumber.trim()),
'_blank',
'noopener,noreferrer'
);
};
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
const formatTimeAgo = (date: Date) => {
const diffMs = Date.now() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t('timeAgo.justNow');
if (diffMins < 60) return t('timeAgo.minutesAgo', { count: diffMins });
if (diffHours < 24) return t('timeAgo.hoursAgo', { count: diffHours });
if (diffDays < 7) return t('timeAgo.daysAgo', { count: diffDays });
return date.toLocaleDateString(locale);
};
const helpCards = [
{ icon: Package, key: 'containerNumber' },
{ icon: FileText, key: 'billOfLading' },
{ icon: ClipboardList, key: 'bookingRef' },
] as const;
return (
<>
<PageHeader title={t('title')} description={t('description')} />
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('searchCard.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<fieldset>
<legend className="mb-2 text-label-lg text-neutral-700">
{t('searchCard.selectCarrier')}
</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{carriers.map(carrier => {
const active = selectedCarrier === carrier.id;
return (
<button
key={carrier.id}
type="button"
aria-pressed={active}
onClick={() => {
setSelectedCarrier(carrier.id);
setError('');
}}
className={cn(
'flex min-h-[44px] items-center gap-2 rounded-md border p-2 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-border hover:bg-neutral-50'
)}
>
<span
aria-hidden="true"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded text-[10px] font-bold',
carrier.textColor
)}
style={{ backgroundColor: carrier.color }}
>
{carrier.name.length <= 3
? carrier.name
: carrier.name.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0 truncate text-body-xs font-medium text-brand-navy">
{carrier.name}
</span>
</button>
);
})}
</div>
</fieldset>
<FormField
label={t('searchCard.trackingNumber')}
hint={
selectedCarrierData
? t(`carriers.${selectedCarrierData.descKey}` as any)
: undefined
}
error={error || undefined}
>
{({ id, describedBy, invalid }) => (
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id={id}
aria-describedby={describedBy}
error={invalid}
value={trackingNumber}
onChange={e => {
setTrackingNumber(e.target.value.toUpperCase());
setError('');
}}
onKeyDown={e => e.key === 'Enter' && handleTrack()}
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
className="font-mono"
/>
<Button onClick={handleTrack} className="shrink-0">
<Search />
{t('searchCard.searchButton')}
</Button>
</div>
)}
</FormField>
<div>
<Button
variant={showMap ? 'secondary' : 'outline'}
onClick={() => {
setShowMap(!showMap);
if (!showMap) setIsMapLoading(true);
}}
>
<Globe />
{showMap ? t('searchCard.hideMap') : t('searchCard.showMap')}
</Button>
</div>
</CardContent>
</Card>
{showMap && (
<div className={cn(isMapFullscreen && 'fixed inset-0 z-50 bg-brand-navy')}>
<Card className={cn('overflow-hidden', isMapFullscreen && 'h-full rounded-none')}>
<div className="flex items-center justify-between gap-3 bg-brand-navy px-4 py-3 text-white">
<div className="flex min-w-0 items-center gap-2">
<Anchor className="size-4 shrink-0 text-brand-turquoise" aria-hidden="true" />
<div className="min-w-0">
<p className="truncate font-heading text-body-sm font-semibold">
{t('map.title')}
</p>
<p className="truncate text-body-xs text-white/60">{t('map.subtitle')}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setIsMapFullscreen(!isMapFullscreen)}
aria-label={isMapFullscreen ? t('map.minimize') : t('map.fullscreen')}
className="text-white/70 hover:bg-white/10 hover:text-white"
>
{isMapFullscreen ? <Minimize2 /> : <Maximize2 />}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setShowMap(false);
setIsMapFullscreen(false);
}}
aria-label={tCommon('close')}
className="text-white/70 hover:bg-white/10 hover:text-white"
>
<X />
</Button>
</div>
</div>
<div
className={cn(
'relative w-full',
isMapFullscreen
? 'h-[calc(100vh-56px)]'
: 'h-[60vh] min-h-96 max-h-[42rem]'
)}
>
{isMapLoading && (
<div
role="status"
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-neutral-50"
>
<Ship className="size-8 animate-pulse text-brand-turquoise" aria-hidden="true" />
<p className="text-body-sm font-medium text-brand-navy">{t('map.loading')}</p>
<p className="text-body-xs text-neutral-500">{t('map.connecting')}</p>
</div>
)}
<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="size-full border-0"
title={t('map.iframeTitle')}
loading="lazy"
onLoad={() => setIsMapLoading(false)}
/>
{/* Legende : ces couleurs reproduisent celles des marqueurs de
MarineTraffic. Les aligner sur la charte rendrait la legende
fausse par rapport a la carte affichee. */}
<div className="absolute bottom-3 left-3 max-w-64 rounded-lg border border-border bg-white/95 p-3 shadow-sm">
<p className="mb-2 text-label uppercase text-neutral-500">{t('map.legend')}</p>
<ul className="flex flex-col gap-1.5 text-body-xs text-neutral-600">
{[
{ color: '#22c55e', label: t('map.cargo') },
{ color: '#ef4444', label: t('map.tankers') },
{ color: '#3b82f6', label: t('map.passengers') },
{ color: '#eab308', label: t('map.highSpeed') },
].map(item => (
<li key={item.label} className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</li>
))}
</ul>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border bg-neutral-50 px-4 py-2">
<p className="truncate text-body-xs text-neutral-500">{t('map.dataSource')}</p>
<a
href="https://www.marinetraffic.com"
target="_blank"
rel="noopener noreferrer"
className="flex shrink-0 items-center gap-1 rounded-md text-body-xs font-medium text-brand-turquoise underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('map.openOnSite')}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</div>
</Card>
</div>
)}
<Card>
<CardHeader className="flex-row items-center justify-between gap-3 pb-3">
<CardTitle className="flex items-center gap-2">
<History className="size-4 text-neutral-400" aria-hidden="true" />
{t('history.title')}
</CardTitle>
{searchHistory.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => saveHistory([])}>
{t('history.clearAll')}
</Button>
)}
</CardHeader>
<CardContent>
{searchHistory.length === 0 ? (
<EmptyState
size="sm"
icon={Clock}
title={t('history.empty')}
description={t('history.emptyHint')}
/>
) : (
<ul className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{searchHistory.map(item => {
const carrier = carriers.find(c => c.id === item.carrierId);
return (
<li
key={item.id}
className="group flex items-center gap-2 rounded-md border border-border p-2 transition-colors hover:bg-neutral-50"
>
<button
type="button"
onClick={() => {
setTrackingNumber(item.trackingNumber);
setSelectedCarrier(item.carrierId);
}}
className="flex min-w-0 flex-1 items-center gap-2 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
aria-hidden="true"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded text-[10px] font-bold',
carrier?.textColor || 'text-white'
)}
style={{ backgroundColor: carrier?.color || '#10183A' }}
>
{item.carrierName.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0">
<span className="block truncate font-mono text-body-sm font-medium text-brand-navy">
{item.trackingNumber}
</span>
<span className="block truncate text-body-xs text-neutral-500">
{item.carrierName} · {formatTimeAgo(item.timestamp)}
</span>
</span>
</button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => saveHistory(searchHistory.filter(h => h.id !== item.id))}
aria-label={`${tCommon('delete')} : ${item.trackingNumber}`}
className="shrink-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
>
<X />
</Button>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
<section className="grid gap-4 md:grid-cols-3">
{helpCards.map(({ icon: Icon, key }) => (
<Card key={key}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-h6">
<Icon className="size-4 text-neutral-400" aria-hidden="true" />
{t(`help.${key}.title` as any)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-body-sm text-neutral-500">
{t(`help.${key}.description` as any)}
</p>
</CardContent>
</Card>
))}
</section>
<Callout variant="info" title={t('infoBox.title')}>
{t('infoBox.description')}
</Callout>
</div>
</>
);
}