xpeditis2.0/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx
2026-08-12 13:09:48 +02:00

1068 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { Search, Loader2, Lock, Sparkles, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import {
getAvailableOrigins,
getAvailableDestinations,
getAvailableDirections,
RoutePortInfo,
} from '@/lib/api/rates';
import { useReservationQuota } from '@/hooks/useReservationQuota';
import { Link } from '@/i18n/navigation';
import dynamic from 'next/dynamic';
const PortRouteMapLoader = () => {
const t = useTranslations('dashboard.rateSearch');
return (
<div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">
{t('mapLoading')}
</div>
);
};
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
ssr: false,
loading: PortRouteMapLoader,
});
export type TradeDirection = 'EXPORT' | 'IMPORT';
interface Package {
type: 'caisse' | 'colis' | 'palette' | 'autre';
quantity: number;
length: number;
width: number;
height: number;
weight: number;
stackable: boolean;
}
interface SearchForm {
origin: string;
destination: string;
packages: Package[];
eurDocument: boolean;
customsStop: boolean;
exportAssistance: boolean;
dangerousGoods: boolean;
specialHandling: boolean;
tailgate: boolean;
straps: boolean;
thermalCover: boolean;
regulatedProducts: boolean;
appointment: boolean;
insurance: boolean;
t1Document: boolean;
}
// Options & services persisted with the booking (order irrelevant).
const OPTION_KEYS: (keyof SearchForm)[] = [
'eurDocument',
't1Document',
'customsStop',
'exportAssistance',
'dangerousGoods',
'regulatedProducts',
'specialHandling',
'tailgate',
'straps',
'thermalCover',
'appointment',
'insurance',
];
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const tLimit = useTranslations('dashboard.bookingsList.limit');
const router = useRouter();
const searchParams = useSearchParams();
const quota = useReservationQuota();
const [editBookingId, setEditBookingId] = useState<string | null>(null);
const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '',
destination: '',
packages: [
{
type: 'palette',
quantity: 1,
length: 120,
width: 80,
height: 100,
weight: 500,
stackable: true,
},
],
eurDocument: false,
customsStop: false,
exportAssistance: false,
dangerousGoods: false,
specialHandling: false,
tailgate: false,
straps: false,
thermalCover: false,
regulatedProducts: false,
appointment: false,
insurance: false,
t1Document: false,
});
// Step 0 = import/export choice, 1 = ports, 2 = cargo, 3 = options
const [currentStep, setCurrentStep] = useState(0);
// Trade direction must be chosen before the origin/destination step:
// EXPORT = French origins, IMPORT = French destinations.
const [direction, setDirection] = useState<TradeDirection | null>(null);
const [originSearch, setOriginSearch] = useState('');
const [destinationSearch, setDestinationSearch] = useState('');
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(
null
);
const { data: directionsData, isLoading: isLoadingDirections } = useQuery({
queryKey: ['available-directions'],
queryFn: getAvailableDirections,
});
const availableDirections = directionsData?.directions ?? [];
// Asking "import or export?" is only meaningful when both are actually
// served; with a single direction the step is dropped and auto-selected.
const showDirectionStep = availableDirections.length > 1;
const wizardSteps = showDirectionStep ? [0, 1, 2, 3] : [1, 2, 3];
const firstStep = wizardSteps[0];
const stepNumber = (step: number) => wizardSteps.indexOf(step) + 1;
useEffect(() => {
if (!directionsData) return;
if (availableDirections.length === 1) {
setDirection(prev => prev ?? availableDirections[0]);
}
if (!showDirectionStep) {
setCurrentStep(prev => (prev === 0 ? 1 : prev));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [directionsData]);
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins', direction],
queryFn: () => getAvailableOrigins(direction ?? undefined),
enabled: !isLoadingDirections && (!!direction || !showDirectionStep),
});
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin, direction],
queryFn: () => getAvailableDestinations(searchForm.origin, direction ?? undefined),
enabled: !!searchForm.origin && !isLoadingDirections,
});
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)
);
});
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)
);
});
useEffect(() => {
if (searchForm.origin && selectedDestinationPort) {
const isValidDestination = destinationsData?.destinations?.some(
d => d.code === searchForm.destination
);
if (!isValidDestination) {
setSearchForm(prev => ({ ...prev, destination: '' }));
setSelectedDestinationPort(null);
setDestinationSearch('');
}
}
}, [searchForm.origin, destinationsData]);
// Prefill the form when re-editing an existing unpaid booking (or from any
// deep link with query params). Runs once on mount.
useEffect(() => {
const originParam = searchParams.get('origin');
const destinationParam = searchParams.get('destination');
const editId = searchParams.get('editBookingId');
const directionParam = searchParams.get('direction');
if (directionParam === 'EXPORT' || directionParam === 'IMPORT') {
setDirection(directionParam);
}
if (!originParam && !destinationParam && !editId) return;
// Deep links and booking edits may carry no direction (bookings don't store
// it): infer it from which end of the lane is French.
if (directionParam !== 'EXPORT' && directionParam !== 'IMPORT') {
setDirection(destinationParam?.startsWith('FR') && !originParam?.startsWith('FR')
? 'IMPORT'
: 'EXPORT');
}
if (editId) setEditBookingId(editId);
const vol = parseFloat(searchParams.get('volumeCBM') || '0');
const wgt = parseFloat(searchParams.get('weightKG') || '0');
const pallets = parseInt(searchParams.get('palletCount') || '0', 10);
const dg = searchParams.get('hasDangerousGoods') === 'true';
// Prefill the options & services from the "options" CSV param.
const enabledOptions = (searchParams.get('options') || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const optionFlags = OPTION_KEYS.reduce(
(acc, k) => ({ ...acc, [k]: enabledOptions.includes(k) }),
{} as Record<string, boolean>
);
// Reconstruct a single representative package that reproduces the aggregate
// totals (the booking only stores totals, not individual packages).
const q = pallets > 0 ? pallets : 1;
const perVolM3 = vol > 0 ? vol / q : 0;
const isPalette = pallets > 0;
// The booking only stores aggregate totals, not per-package dimensions. For
// pallets we reconstruct the standard EUR pallet footprint (120×80 cm) and
// derive the height from the volume — so a default 0.96 CBM pallet comes back
// as 120×80×100. For loose parcels we fall back to a cube of equal volume.
let length: number;
let width: number;
let height: number;
if (isPalette) {
length = 120;
width = 80;
const baseM2 = (length / 100) * (width / 100); // 0.96 m²
height = perVolM3 > 0 ? Math.round((perVolM3 / baseM2) * 100 * 100) / 100 : 100;
} else {
const sideCm = perVolM3 > 0 ? Math.round(Math.cbrt(perVolM3) * 100 * 100) / 100 : 0;
length = sideCm;
width = sideCm;
height = sideCm;
}
const pkg = {
type: (isPalette ? 'palette' : 'colis') as Package['type'],
quantity: q,
length,
width,
height,
weight: wgt > 0 ? Math.round((wgt / q) * 100) / 100 : 0,
stackable: true,
};
setSearchForm(prev => ({
...prev,
...optionFlags,
origin: originParam || prev.origin,
destination: destinationParam || prev.destination,
dangerousGoods: dg || !!optionFlags.dangerousGoods,
packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages,
}));
if (originParam) setOriginSearch(originParam);
if (destinationParam) setDestinationSearch(destinationParam);
// Start at the cargo (packages) step as requested.
setCurrentStep(2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Once port lists load, resolve the prefilled codes to full port objects so
// the selection UI reflects them (and destination validation passes).
useEffect(() => {
if (searchForm.origin && !selectedOriginPort) {
const p = (originsData?.origins || []).find(o => o.code === searchForm.origin);
if (p) {
setSelectedOriginPort(p);
setOriginSearch(p.displayName);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originsData, searchForm.origin]);
useEffect(() => {
if (searchForm.destination && !selectedDestinationPort) {
const p = (destinationsData?.destinations || []).find(
d => d.code === searchForm.destination
);
if (p) {
setSelectedDestinationPort(p);
setDestinationSearch(p.displayName);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destinationsData, searchForm.destination]);
const calculateTotals = () => {
let totalVolumeCBM = 0;
let totalWeightKG = 0;
let totalPallets = 0;
searchForm.packages.forEach(pkg => {
const volumeM3 = (pkg.length * pkg.width * pkg.height) / 1000000;
totalVolumeCBM += volumeM3 * pkg.quantity;
totalWeightKG += pkg.weight * pkg.quantity;
if (pkg.type === 'palette') {
totalPallets += pkg.quantity;
}
});
return { totalVolumeCBM, totalWeightKG, totalPallets };
};
const handleSearch = () => {
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
const params = new URLSearchParams({
origin: searchForm.origin,
destination: searchForm.destination,
volumeCBM: totalVolumeCBM.toString(),
weightKG: totalWeightKG.toString(),
palletCount: totalPallets.toString(),
hasDangerousGoods: searchForm.dangerousGoods.toString(),
requiresSpecialHandling: searchForm.specialHandling.toString(),
requiresTailgate: searchForm.tailgate.toString(),
requiresStraps: searchForm.straps.toString(),
requiresThermalCover: searchForm.thermalCover.toString(),
hasRegulatedProducts: searchForm.regulatedProducts.toString(),
requiresAppointment: searchForm.appointment.toString(),
});
if (direction) {
params.set('direction', direction);
}
if (editBookingId) {
params.set('editBookingId', editBookingId);
}
// Carry the selected options & services so they can be persisted.
const enabledOptions = OPTION_KEYS.filter(k => searchForm[k]).join(',');
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
};
const addPackage = () => {
setSearchForm({
...searchForm,
packages: [
...searchForm.packages,
{
type: 'palette',
quantity: 1,
length: 120,
width: 80,
height: 100,
weight: 500,
stackable: true,
},
],
});
};
const removePackage = (index: number) => {
setSearchForm({
...searchForm,
packages: searchForm.packages.filter((_, i) => i !== index),
});
};
const updatePackage = (index: number, field: keyof Package, value: any) => {
const newPackages = [...searchForm.packages];
newPackages[index] = { ...newPackages[index], [field]: value };
setSearchForm({ ...searchForm, packages: newPackages });
};
const selectDirection = (value: TradeDirection) => {
// Switching direction invalidates the picked ports (an export origin is
// never a valid import origin); re-picking the same one keeps them.
if (value !== direction) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
setSelectedDestinationPort(null);
setOriginSearch('');
setDestinationSearch('');
}
setDirection(value);
setCurrentStep(1);
};
const renderStep0 = () => (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">
{stepNumber(0)}. {t('step0.title')}
</h2>
<p className="text-sm text-gray-500 mt-1">{t('step0.subtitle')}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(
[
{ value: 'EXPORT' as const, Icon: ArrowUpFromLine },
{ value: 'IMPORT' as const, Icon: ArrowDownToLine },
] as const
)
.filter(({ value }) => availableDirections.includes(value))
.map(({ value, Icon }) => {
const isSelected = direction === value;
const key = value.toLowerCase() as 'export' | 'import';
return (
<button
key={value}
type="button"
onClick={() => selectDirection(value)}
className={`text-left p-6 rounded-xl border-2 transition-colors ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
}`}
>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
isSelected ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'
}`}
>
<Icon className="h-6 w-6" />
</div>
<div className="font-semibold text-gray-900">{t(`step0.${key}.title`)}</div>
<div className="text-sm text-gray-600 mt-1">{t(`step0.${key}.route`)}</div>
<div className="text-xs text-gray-500 mt-2">{t(`step0.${key}.description`)}</div>
</button>
);
})}
</div>
</div>
);
const renderStep1 = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
{stepNumber(1)}. {t('step1.title')}
</h2>
{direction && showDirectionStep && (
<button
type="button"
onClick={() => setCurrentStep(0)}
className="text-xs font-medium px-3 py-1 rounded-full bg-blue-100 text-blue-800 hover:bg-blue-200"
>
{t(`step0.${direction.toLowerCase() as 'export' | 'import'}.title`)} · {t('step0.change')}
</button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('step1.originLabel')}{' '}
{searchForm.origin && (
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
)}
</label>
<div className="relative">
<input
type="text"
value={originSearch}
onChange={e => {
setOriginSearch(e.target.value);
setShowOriginDropdown(true);
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
setSelectedDestinationPort(null);
setDestinationSearch('');
}
}}
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder={t('step1.originPlaceholder')}
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'
}`}
/>
{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">
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
setOriginSearch(port.displayName);
setSelectedOriginPort(port);
setSelectedDestinationPort(null);
setDestinationSearch('');
setShowOriginDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
>
<div className="font-medium text-gray-900">{port.name}</div>
<div className="text-gray-500 text-xs mt-1">
{port.code} - {port.city}, {port.countryName}
</div>
</button>
))}
{filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
{t('step1.moreResults', { count: filteredOrigins.length - 15 })}
</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">
{t('step1.noOrigin', { query: originSearch })}
</p>
</div>
)}
</div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('step1.destinationLabel')}{' '}
{searchForm.destination && (
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
)}
</label>
<div className="relative">
<input
type="text"
value={destinationSearch}
onChange={e => {
setDestinationSearch(e.target.value);
setShowDestinationDropdown(true);
if (
selectedDestinationPort &&
e.target.value !== selectedDestinationPort.displayName
) {
setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null);
}
}}
onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin}
placeholder={
searchForm.origin
? t('step1.destinationPlaceholder')
: t('step1.destinationDisabled')
}
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.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
/>
{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">
{t('step1.availableDestinations', {
count: destinationsData.total,
port: 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">
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, destination: port.code });
setDestinationSearch(port.displayName);
setSelectedDestinationPort(port);
setShowDestinationDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
>
<div className="font-medium text-gray-900">{port.name}</div>
<div className="text-gray-500 text-xs mt-1">
{port.code} - {port.city}, {port.countryName}
</div>
</button>
))}
{filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
{t('step1.moreResults', { count: filteredDestinations.length - 15 })}
</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">
{t('step1.noDestination', { query: destinationSearch })}
</p>
</div>
)}
</div>
</div>
{selectedOriginPort &&
selectedDestinationPort &&
selectedOriginPort.latitude &&
selectedDestinationPort.latitude && (
<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">
<h3 className="text-sm font-semibold text-gray-900">
{t('step1.routeTitle', {
origin: selectedOriginPort.name,
destination: selectedDestinationPort.name,
})}
</h3>
<p className="text-xs text-gray-500 mt-1">{t('step1.routeDescription')}</p>
</div>
<PortRouteMap
portA={{
lat: selectedOriginPort.latitude,
lng: selectedOriginPort.longitude!,
}}
portB={{
lat: selectedDestinationPort.latitude,
lng: selectedDestinationPort.longitude!,
}}
height="400px"
/>
</div>
)}
</div>
);
const renderStep2 = () => {
const totals = calculateTotals();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
{stepNumber(2)}. {t('step2.title')}
</h2>
<button
type="button"
onClick={addPackage}
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
>
{t('step2.addPackage')}
</button>
</div>
{searchForm.packages.map((pkg, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">
{t('step2.packageNumber', { number: index + 1 })}
</h3>
{searchForm.packages.length > 1 && (
<button
type="button"
onClick={() => removePackage(index)}
className="text-sm text-red-600 hover:text-red-700"
>
{t('step2.remove')}
</button>
)}
</div>
<div className="grid grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.type')}
</label>
<select
value={pkg.type}
onChange={e => updatePackage(index, 'type', e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
>
<option value="caisse">{t('step2.packageTypes.caisse')}</option>
<option value="colis">{t('step2.packageTypes.colis')}</option>
<option value="palette">{t('step2.packageTypes.palette')}</option>
<option value="autre">{t('step2.packageTypes.autre')}</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.quantity')}
</label>
<input
type="number"
min="1"
value={pkg.quantity}
onChange={e => updatePackage(index, 'quantity', parseInt(e.target.value) || 1)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.length')}
</label>
<input
type="number"
min="1"
value={pkg.length}
onChange={e => updatePackage(index, 'length', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.width')}
</label>
<input
type="number"
min="1"
value={pkg.width}
onChange={e => updatePackage(index, 'width', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.height')}
</label>
<input
type="number"
min="1"
value={pkg.height}
onChange={e => updatePackage(index, 'height', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{t('step2.weight')}
</label>
<input
type="number"
min="1"
value={pkg.weight}
onChange={e => updatePackage(index, 'weight', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
/>
</div>
<div className="flex items-center pt-6">
<input
type="checkbox"
checked={pkg.stackable}
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label className="ml-2 text-sm text-gray-700">{t('step2.stackable')}</label>
</div>
</div>
</div>
))}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">{t('step2.summary.title')}</h3>
<div className="text-sm text-blue-800 space-y-1">
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
<div>{t('step2.summary.weight', { value: totals.totalWeightKG })}</div>
<div>{t('step2.summary.pallets', { value: totals.totalPallets })}</div>
</div>
</div>
</div>
);
};
const renderStep3 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">
{stepNumber(3)}. {t('step3.title')}
</h2>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.customs.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.eurDocument}
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.eurDocument')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.t1Document}
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.t1Document')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.customsStop}
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.customsStop')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.exportAssistance}
onChange={e => setSearchForm({ ...searchForm, exportAssistance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">
{t('step3.customs.exportAssistance')}
</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.goods.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.dangerousGoods}
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.dangerous')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.regulatedProducts}
onChange={e =>
setSearchForm({ ...searchForm, regulatedProducts: e.target.checked })
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.regulated')}</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.handling.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.specialHandling}
onChange={e => setSearchForm({ ...searchForm, specialHandling: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.tailgate}
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.tailgate')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.straps}
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.straps')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.thermalCover}
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.thermalCover')}</span>
</label>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.other.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.appointment}
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.appointment')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.insurance}
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.insurance')}</span>
</label>
</div>
</div>
</div>
</div>
);
// Block starting a NEW reservation when the free-plan yearly limit is reached.
// Editing an existing unpaid booking (editBookingId) does not create a new
// shipment, so it is never blocked.
const isEditing = !!searchParams.get('editBookingId');
if (quota.blocked && !isEditing && !quota.loading) {
return (
<div className="max-w-2xl mx-auto py-10 px-4">
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mb-4">
<Lock className="h-7 w-7 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{tLimit('title')}</h1>
<p className="text-gray-600 mb-6">{tLimit('message', { max: quota.max })}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-amber-500 text-white rounded-lg font-semibold hover:bg-amber-600 transition-colors"
>
<Sparkles className="h-5 w-5" />
{tLimit('upgrade')}
</Link>
<Link
href="/dashboard/bookings"
className="inline-flex items-center justify-center px-6 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
{tLimit('back')}
</Link>
</div>
</div>
</div>
);
}
// The wizard shape depends on how many directions are served, so wait for
// that answer rather than flashing a step that may not exist.
if (isLoadingDirections) {
return (
<div className="max-w-7xl mx-auto py-20 flex justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-500 mt-1">{t('subtitle')}</p>
</div>
<div className="flex items-center justify-center space-x-4">
{wizardSteps.map((step, index) => (
<div key={step} className="flex items-center">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full ${
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
}`}
>
{index + 1}
</div>
{index < wizardSteps.length - 1 && (
<div
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-blue-600' : 'bg-gray-200'}`}
/>
)}
</div>
))}
</div>
<div className="bg-white rounded-lg shadow p-8">
{currentStep === 0 && renderStep0()}
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
<div className="mt-8 flex items-center justify-between pt-6 border-t">
<button
type="button"
onClick={() => setCurrentStep(Math.max(firstStep, currentStep - 1))}
disabled={currentStep === firstStep}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('navigation.previous')}
</button>
{currentStep < 3 ? (
<button
type="button"
onClick={() => setCurrentStep(currentStep + 1)}
disabled={
currentStep === 0
? !direction
: !searchForm.origin || !searchForm.destination
}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('navigation.next')}
</button>
) : (
<button
type="button"
onClick={handleSearch}
disabled={!direction || !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"
>
<Search className="h-5 w-5 mr-2" /> {t('navigation.search')}
</button>
)}
</div>
</div>
</div>
);
}