Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m51s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Successful in 10m57s
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Failing after 12m28s
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Has been skipped
598 lines
24 KiB
TypeScript
598 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 { searchRates } from '@/lib/api';
|
|
|
|
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
|
|
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
|
|
|
|
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: 'SEA',
|
|
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
|
|
// TODO: Implement searchPorts API endpoint
|
|
const { data: originPorts } = useQuery({
|
|
queryKey: ['ports', originSearch],
|
|
queryFn: async () => [],
|
|
enabled: false, // Disabled until API is implemented
|
|
});
|
|
|
|
const { data: destinationPorts } = useQuery({
|
|
queryKey: ['ports', destinationSearch],
|
|
queryFn: async () => [],
|
|
enabled: false, // Disabled until API is implemented
|
|
});
|
|
|
|
// Rate search
|
|
const {
|
|
data: rateQuotes,
|
|
isLoading: isSearching,
|
|
error: searchError,
|
|
} = useQuery({
|
|
queryKey: ['rates', searchForm],
|
|
queryFn: () =>
|
|
searchRates({
|
|
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?.rates
|
|
? rateQuotes.rates
|
|
.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?.rates
|
|
? Array.from(new Set(rateQuotes.rates.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>
|
|
);
|
|
}
|