Le plan gratuit (BRONZE) est limite a maxShipmentsPerYear (5/an). Cote frontend, rien ne le refletait: le bouton 'Nouvelle Reservation' restait actif. Ajout d'un hook useReservationQuota (limite du plan vs reservations de l'annee) qui: - remplace le bouton 'Nouvelle Reservation' par un bouton 'Ameliorer mon abonnement' + banniere sur la liste des reservations quand la limite est atteinte; - bloque la page de recherche (nouvelle resa) avec un ecran d'upgrade, sauf en mode edition d'une resa existante. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
922 lines
36 KiB
TypeScript
922 lines
36 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from '@/i18n/navigation';
|
||
import { useSearchParams } from 'next/navigation';
|
||
import { Search, Loader2, Lock, Sparkles } from 'lucide-react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useTranslations } from 'next-intl';
|
||
import { getAvailableOrigins, getAvailableDestinations, 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,
|
||
});
|
||
|
||
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,
|
||
});
|
||
|
||
const [currentStep, setCurrentStep] = useState(1);
|
||
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: originsData, isLoading: isLoadingOrigins } = useQuery({
|
||
queryKey: ['available-origins'],
|
||
queryFn: getAvailableOrigins,
|
||
});
|
||
|
||
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
|
||
queryKey: ['available-destinations', searchForm.origin],
|
||
queryFn: () => getAvailableDestinations(searchForm.origin),
|
||
enabled: !!searchForm.origin,
|
||
});
|
||
|
||
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');
|
||
if (!originParam && !destinationParam && !editId) return;
|
||
|
||
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 (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 renderStep1 = () => (
|
||
<div className="space-y-6">
|
||
<h2 className="text-xl font-semibold text-gray-900">{t('step1.title')}</h2>
|
||
|
||
<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">{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">{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>
|
||
);
|
||
}
|
||
|
||
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">
|
||
{[1, 2, 3].map(step => (
|
||
<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'
|
||
}`}
|
||
>
|
||
{step}
|
||
</div>
|
||
{step < 3 && (
|
||
<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 === 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(1, currentStep - 1))}
|
||
disabled={currentStep === 1}
|
||
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={!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={!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>
|
||
);
|
||
}
|