545 lines
20 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|