389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||
import type { CsvRateSearchResult } from '@/types/rates';
|
||
|
||
interface BestOptions {
|
||
eco: CsvRateSearchResult;
|
||
standard: CsvRateSearchResult;
|
||
fast: CsvRateSearchResult;
|
||
}
|
||
|
||
export default function SearchResultsPage() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const [results, setResults] = useState<CsvRateSearchResult[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Parse search parameters from URL
|
||
const origin = searchParams.get('origin') || '';
|
||
const destination = searchParams.get('destination') || '';
|
||
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
||
const weightKG = parseFloat(searchParams.get('weightKG') || '0');
|
||
const palletCount = parseInt(searchParams.get('palletCount') || '0');
|
||
|
||
useEffect(() => {
|
||
if (!origin || !destination || !volumeCBM || !weightKG) {
|
||
router.push('/dashboard/search-advanced');
|
||
return;
|
||
}
|
||
|
||
performSearch();
|
||
}, [origin, destination, volumeCBM, weightKG, palletCount]);
|
||
|
||
const performSearch = async () => {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const response = await searchCsvRatesWithOffers({
|
||
origin,
|
||
destination,
|
||
volumeCBM,
|
||
weightKG,
|
||
palletCount,
|
||
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
|
||
requiresSpecialHandling: searchParams.get('requiresSpecialHandling') === 'true',
|
||
requiresTailgate: searchParams.get('requiresTailgate') === 'true',
|
||
requiresStraps: searchParams.get('requiresStraps') === 'true',
|
||
requiresThermalCover: searchParams.get('requiresThermalCover') === 'true',
|
||
hasRegulatedProducts: searchParams.get('hasRegulatedProducts') === 'true',
|
||
requiresAppointment: searchParams.get('requiresAppointment') === 'true',
|
||
});
|
||
|
||
setResults(response.results);
|
||
} catch (err) {
|
||
console.error('Search error:', err);
|
||
setError(err instanceof Error ? err.message : 'Une erreur est survenue lors de la recherche');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const getBestOptions = (): BestOptions | null => {
|
||
if (results.length === 0) return null;
|
||
|
||
// Filter results by serviceLevel (backend generates 3 offers per rate)
|
||
const economic = results.find(r => r.serviceLevel === 'ECONOMIC');
|
||
const standard = results.find(r => r.serviceLevel === 'STANDARD');
|
||
const rapid = results.find(r => r.serviceLevel === 'RAPID');
|
||
|
||
// If we have all 3 service levels, return them
|
||
if (economic && standard && rapid) {
|
||
return {
|
||
eco: economic,
|
||
standard: standard,
|
||
fast: rapid,
|
||
};
|
||
}
|
||
|
||
// Fallback: if serviceLevel is not present (old endpoint), use sorting
|
||
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
|
||
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
|
||
|
||
return {
|
||
eco: sorted[0],
|
||
standard: sorted[Math.floor(sorted.length / 2)] || sorted[0],
|
||
fast: fastest[0],
|
||
};
|
||
};
|
||
|
||
const bestOptions = getBestOptions();
|
||
|
||
const formatPrice = (price: number) => {
|
||
return new Intl.NumberFormat('fr-FR', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
}).format(price);
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||
<div className="max-w-7xl mx-auto">
|
||
<div className="text-center">
|
||
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mb-4"></div>
|
||
<p className="text-xl text-gray-700 font-medium">Recherche des meilleurs tarifs en cours...</p>
|
||
<p className="text-gray-500 mt-2">
|
||
{origin} → {destination}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||
<div className="max-w-7xl mx-auto">
|
||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||
<div className="text-6xl mb-4">❌</div>
|
||
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
|
||
<p className="text-red-700 mb-4">{error}</p>
|
||
<button
|
||
onClick={() => router.push('/dashboard/search-advanced')}
|
||
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||
>
|
||
Retour à la recherche
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (results.length === 0) {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||
<div className="max-w-7xl mx-auto">
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||
>
|
||
← Retour à la recherche
|
||
</button>
|
||
|
||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
||
<div className="text-6xl mb-4">🔍</div>
|
||
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
|
||
<p className="text-gray-600 mb-4">
|
||
Aucun tarif ne correspond à votre recherche pour le trajet {origin} → {destination}
|
||
</p>
|
||
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
||
<h4 className="font-semibold text-gray-900 mb-2">💡 Suggestions :</h4>
|
||
<ul className="text-sm text-gray-700 space-y-2">
|
||
<li>
|
||
• <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX,
|
||
CNSHG, SGSIN (destination)
|
||
</li>
|
||
<li>
|
||
• <strong>Volume :</strong> Essayez entre 1 et 200 CBM
|
||
</li>
|
||
<li>
|
||
• <strong>Poids :</strong> Essayez entre 100 et 30000 kg
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
Modifier la recherche
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const optionCards = [
|
||
{
|
||
type: 'Économique',
|
||
option: bestOptions?.eco,
|
||
colors: {
|
||
border: 'border-green-200',
|
||
bg: 'bg-green-50',
|
||
text: 'text-green-800',
|
||
button: 'bg-green-600 hover:bg-green-700',
|
||
},
|
||
icon: '💰',
|
||
badge: 'Le moins cher',
|
||
},
|
||
{
|
||
type: 'Standard',
|
||
option: bestOptions?.standard,
|
||
colors: {
|
||
border: 'border-blue-200',
|
||
bg: 'bg-blue-50',
|
||
text: 'text-blue-800',
|
||
button: 'bg-blue-600 hover:bg-blue-700',
|
||
},
|
||
icon: '⚖️',
|
||
badge: 'Équilibré',
|
||
},
|
||
{
|
||
type: 'Rapide',
|
||
option: bestOptions?.fast,
|
||
colors: {
|
||
border: 'border-purple-200',
|
||
bg: 'bg-purple-50',
|
||
text: 'text-purple-800',
|
||
button: 'bg-purple-600 hover:bg-purple-700',
|
||
},
|
||
icon: '⚡',
|
||
badge: 'Le plus rapide',
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||
>
|
||
← Retour à la recherche
|
||
</button>
|
||
|
||
<div className="bg-white rounded-lg shadow-md p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Résultats de recherche</h1>
|
||
<p className="text-gray-600">
|
||
<span className="font-semibold">{origin}</span> → <span className="font-semibold">{destination}</span>{' '}
|
||
• {volumeCBM} CBM • {weightKG} kg
|
||
{palletCount > 0 && ` • ${palletCount} palette${palletCount > 1 ? 's' : ''}`}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-sm text-gray-500">Tarifs trouvés</p>
|
||
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Best Options */}
|
||
{bestOptions && (
|
||
<div className="mb-12">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
||
<span className="text-3xl mr-3">🏆</span>
|
||
Meilleurs choix pour votre recherche
|
||
</h2>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
{optionCards.map(card => {
|
||
if (!card.option) return null;
|
||
|
||
return (
|
||
<div
|
||
key={card.type}
|
||
className={`border-2 ${card.colors.border} ${card.colors.bg} rounded-xl shadow-lg overflow-hidden transform transition-all duration-300 hover:scale-105 hover:shadow-2xl`}
|
||
>
|
||
<div className={`p-6 ${card.colors.bg}`}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center space-x-3">
|
||
<span className="text-4xl">{card.icon}</span>
|
||
<div>
|
||
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
|
||
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
|
||
{card.badge}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-lg p-4 mb-4">
|
||
<div className="text-center mb-3">
|
||
<p className="text-sm text-gray-600 mb-1">Prix total</p>
|
||
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p>
|
||
</div>
|
||
|
||
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Transporteur :</span>
|
||
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Transit :</span>
|
||
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">Type :</span>
|
||
<span className="font-semibold text-gray-900">{card.option.containerType}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => {
|
||
const rateData = encodeURIComponent(JSON.stringify(card.option));
|
||
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`);
|
||
}}
|
||
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
|
||
>
|
||
Sélectionner cette option
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* All Results */}
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Tous les tarifs disponibles ({results.length})</h2>
|
||
|
||
<div className="space-y-4">
|
||
{results.map((result, index) => (
|
||
<div key={index} className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
|
||
<p className="text-sm text-gray-500">
|
||
{result.origin} → {result.destination} • {result.containerType}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p>
|
||
<p className="text-sm text-gray-500">Prix total</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-xs text-gray-600 mb-1">Prix de base</p>
|
||
<p className="font-semibold text-gray-900">
|
||
{formatPrice(result.priceBreakdown.basePrice)}
|
||
</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-xs text-gray-600 mb-1">Frais volume</p>
|
||
<p className="font-semibold text-gray-900">
|
||
{formatPrice(result.priceBreakdown.volumeCharge)}
|
||
</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-xs text-gray-600 mb-1">Frais poids</p>
|
||
<p className="font-semibold text-gray-900">
|
||
{formatPrice(result.priceBreakdown.weightCharge)}
|
||
</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-xs text-gray-600 mb-1">Délai transit</p>
|
||
<p className="font-semibold text-gray-900">{result.transitDays} jours</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||
<span>✓ Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
|
||
{result.hasSurcharges && <span className="text-orange-600">⚠️ Surcharges applicables</span>}
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const rateData = encodeURIComponent(JSON.stringify(result));
|
||
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`);
|
||
}}
|
||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
Sélectionner
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|