xpeditis2.0/apps/frontend/app/dashboard/search/page.tsx
David-Henri ARNAUD b31d325646 feature phase 2
2025-10-10 15:07:05 +02:00

603 lines
24 KiB
TypeScript

/**
* Rate Search Page
*
* Search and compare shipping rates from multiple carriers
*/
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ratesApi } from '@/lib/api';
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
type Mode = 'FCL' | 'LCL';
interface SearchForm {
originPort: string;
destinationPort: string;
containerType: ContainerType;
departureDate: string;
mode: Mode;
isHazmat: boolean;
quantity: number;
}
export default function RateSearchPage() {
const [searchForm, setSearchForm] = useState<SearchForm>({
originPort: '',
destinationPort: '',
containerType: '40HC',
departureDate: '',
mode: 'FCL',
isHazmat: false,
quantity: 1,
});
const [hasSearched, setHasSearched] = useState(false);
const [originSearch, setOriginSearch] = useState('');
const [destinationSearch, setDestinationSearch] = useState('');
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
const [transitTimeMax, setTransitTimeMax] = useState<number>(50);
const [selectedCarriers, setSelectedCarriers] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'price' | 'transitTime' | 'co2'>('price');
// Port autocomplete
const { data: originPorts } = useQuery({
queryKey: ['ports', originSearch],
queryFn: () => ratesApi.searchPorts(originSearch),
enabled: originSearch.length >= 2,
});
const { data: destinationPorts } = useQuery({
queryKey: ['ports', destinationSearch],
queryFn: () => ratesApi.searchPorts(destinationSearch),
enabled: destinationSearch.length >= 2,
});
// Rate search
const {
data: rateQuotes,
isLoading: isSearching,
error: searchError,
} = useQuery({
queryKey: ['rates', searchForm],
queryFn: () =>
ratesApi.search({
origin: searchForm.originPort,
destination: searchForm.destinationPort,
containerType: searchForm.containerType,
departureDate: searchForm.departureDate,
mode: searchForm.mode,
isHazmat: searchForm.isHazmat,
quantity: searchForm.quantity,
}),
enabled: hasSearched && !!searchForm.originPort && !!searchForm.destinationPort,
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setHasSearched(true);
};
// Filter and sort results
const filteredAndSortedQuotes = rateQuotes
? rateQuotes
.filter((quote: any) => {
const price = quote.pricing.totalAmount;
const inPriceRange = price >= priceRange[0] && price <= priceRange[1];
const inTransitTime = quote.route.transitDays <= transitTimeMax;
const matchesCarrier =
selectedCarriers.length === 0 ||
selectedCarriers.includes(quote.carrier.name);
return inPriceRange && inTransitTime && matchesCarrier;
})
.sort((a: any, b: any) => {
if (sortBy === 'price') {
return a.pricing.totalAmount - b.pricing.totalAmount;
} else if (sortBy === 'transitTime') {
return a.route.transitDays - b.route.transitDays;
} else {
return (a.co2Emissions?.value || 0) - (b.co2Emissions?.value || 0);
}
})
: [];
// Get unique carriers for filter
const availableCarriers = rateQuotes
? Array.from(new Set(rateQuotes.map((q: any) => q.carrier.name)))
: [];
const toggleCarrier = (carrier: string) => {
setSelectedCarriers((prev) =>
prev.includes(carrier) ? prev.filter((c) => c !== carrier) : [...prev, carrier]
);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Search Shipping Rates</h1>
<p className="text-sm text-gray-500 mt-1">
Compare rates from multiple carriers in real-time
</p>
</div>
{/* Search Form */}
<div className="bg-white rounded-lg shadow p-6">
<form onSubmit={handleSearch} className="space-y-6">
{/* Ports */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Origin Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Origin Port *
</label>
<input
type="text"
required
value={originSearch}
onChange={(e) => {
setOriginSearch(e.target.value);
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, originPort: '' });
}
}}
placeholder="e.g., Rotterdam, Shanghai"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{originPorts && originPorts.length > 0 && (
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
{originPorts.map((port: any) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, originPort: port.code });
setOriginSearch(`${port.name} (${port.code})`);
}}
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
>
<div className="font-medium">{port.name}</div>
<div className="text-gray-500 text-xs">
{port.code} - {port.country}
</div>
</button>
))}
</div>
)}
</div>
{/* Destination Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Destination Port *
</label>
<input
type="text"
required
value={destinationSearch}
onChange={(e) => {
setDestinationSearch(e.target.value);
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, destinationPort: '' });
}
}}
placeholder="e.g., Los Angeles, Hamburg"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{destinationPorts && destinationPorts.length > 0 && (
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
{destinationPorts.map((port: any) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, destinationPort: port.code });
setDestinationSearch(`${port.name} (${port.code})`);
}}
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
>
<div className="font-medium">{port.name}</div>
<div className="text-gray-500 text-xs">
{port.code} - {port.country}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Container & Mode */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Container Type *
</label>
<select
value={searchForm.containerType}
onChange={(e) =>
setSearchForm({ ...searchForm, containerType: e.target.value as ContainerType })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="20GP">20' GP</option>
<option value="40GP">40' GP</option>
<option value="40HC">40' HC</option>
<option value="45HC">45' HC</option>
<option value="20RF">20' Reefer</option>
<option value="40RF">40' Reefer</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quantity *
</label>
<input
type="number"
min="1"
max="100"
value={searchForm.quantity}
onChange={(e) =>
setSearchForm({ ...searchForm, quantity: parseInt(e.target.value) || 1 })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Departure Date *
</label>
<input
type="date"
required
value={searchForm.departureDate}
onChange={(e) => setSearchForm({ ...searchForm, departureDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
<select
value={searchForm.mode}
onChange={(e) => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="FCL">FCL (Full Container Load)</option>
<option value="LCL">LCL (Less than Container Load)</option>
</select>
</div>
</div>
{/* Hazmat */}
<div className="flex items-center">
<input
type="checkbox"
id="hazmat"
checked={searchForm.isHazmat}
onChange={(e) => setSearchForm({ ...searchForm, isHazmat: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
Hazardous Materials (requires special handling)
</label>
</div>
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={!searchForm.originPort || !searchForm.destinationPort || isSearching}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSearching ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Searching...
</>
) : (
<>
<span className="mr-2">🔍</span>
Search Rates
</>
)}
</button>
</div>
</form>
</div>
{/* Error */}
{searchError && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">
Failed to search rates. Please try again.
</div>
</div>
)}
{/* Results */}
{hasSearched && !isSearching && rateQuotes && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Filters Sidebar */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Sort By</h3>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="price">Price (Low to High)</option>
<option value="transitTime">Transit Time</option>
<option value="co2">CO2 Emissions</option>
</select>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Price Range (USD)
</h3>
<div className="space-y-2">
<input
type="range"
min="0"
max="10000"
step="100"
value={priceRange[1]}
onChange={(e) => setPriceRange([0, parseInt(e.target.value)])}
className="w-full"
/>
<div className="text-sm text-gray-600">
Up to ${priceRange[1].toLocaleString()}
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Max Transit Time (days)
</h3>
<div className="space-y-2">
<input
type="range"
min="1"
max="50"
value={transitTimeMax}
onChange={(e) => setTransitTimeMax(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-sm text-gray-600">{transitTimeMax} days</div>
</div>
</div>
{availableCarriers.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Carriers</h3>
<div className="space-y-2">
{availableCarriers.map((carrier) => (
<label key={carrier} className="flex items-center">
<input
type="checkbox"
checked={selectedCarriers.includes(carrier as string)}
onChange={() => toggleCarrier(carrier as string)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{carrier}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
{/* Results List */}
<div className="lg:col-span-3 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{filteredAndSortedQuotes.length} Rate{filteredAndSortedQuotes.length !== 1 ? 's' : ''} Found
</h2>
</div>
{filteredAndSortedQuotes.length === 0 ? (
<div className="bg-white rounded-lg shadow p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No rates found</h3>
<p className="mt-1 text-sm text-gray-500">
Try adjusting your filters or search criteria
</p>
</div>
) : (
filteredAndSortedQuotes.map((quote: any) => (
<div
key={quote.id}
className="bg-white rounded-lg shadow hover:shadow-md transition-shadow p-6"
>
<div className="flex items-start justify-between">
{/* Carrier Info */}
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
{quote.carrier.logoUrl ? (
<img
src={quote.carrier.logoUrl}
alt={quote.carrier.name}
className="h-12 w-12 object-contain"
/>
) : (
<div className="h-12 w-12 bg-blue-100 rounded flex items-center justify-center text-blue-600 font-semibold">
{quote.carrier.name.substring(0, 2).toUpperCase()}
</div>
)}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{quote.carrier.name}
</h3>
<p className="text-sm text-gray-500">{quote.carrier.scac}</p>
</div>
</div>
{/* Price */}
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">
${quote.pricing.totalAmount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">{quote.pricing.currency}</div>
</div>
</div>
{/* Route Info */}
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<div className="text-xs text-gray-500 uppercase">Departure</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.etd).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Transit Time</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{quote.route.transitDays} days
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Arrival</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.eta).toLocaleDateString()}
</div>
</div>
</div>
{/* Route Path */}
<div className="mt-4 flex items-center text-sm text-gray-600">
<span className="font-medium">{quote.route.originPort}</span>
<svg
className="mx-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
{quote.route.transshipmentPorts && quote.route.transshipmentPorts.length > 0 && (
<>
<span className="text-gray-400">
via {quote.route.transshipmentPorts.join(', ')}
</span>
<svg
className="mx-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</>
)}
<span className="font-medium">{quote.route.destinationPort}</span>
</div>
{/* Additional Info */}
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
{quote.co2Emissions && (
<div className="flex items-center">
<span className="mr-1">🌱</span>
{quote.co2Emissions.value} kg CO2
</div>
)}
{quote.availability && (
<div className="flex items-center">
<span className="mr-1">📦</span>
{quote.availability} containers available
</div>
)}
</div>
{/* Surcharges */}
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
<div className="mt-4 text-sm">
<div className="text-gray-500 mb-2">Includes surcharges:</div>
<div className="flex flex-wrap gap-2">
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
<span
key={idx}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{surcharge.name}: ${surcharge.amount}
</span>
))}
</div>
</div>
)}
{/* Actions */}
<div className="mt-6 flex justify-end">
<a
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Book Now
</a>
</div>
</div>
))
)}
</div>
</div>
)}
{/* Empty State */}
{!hasSearched && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">Search for Shipping Rates</h3>
<p className="mt-2 text-sm text-gray-500">
Enter your origin, destination, and container details to compare rates from multiple carriers
</p>
</div>
)}
</div>
);
}