mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -35,6 +35,7 @@ export default function SeatsPage() {
|
||||
queryKey: ['seatmap', selectedSchedule],
|
||||
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
|
||||
enabled: !!selectedSchedule,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: coachTypesData } = useQuery({
|
||||
@@ -49,6 +50,7 @@ export default function SeatsPage() {
|
||||
|
||||
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
|
||||
queryKey: ['routeCoaches', selectedRoute],
|
||||
staleTime: 0,
|
||||
queryFn: async () => {
|
||||
if (!selectedRoute) return null;
|
||||
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
|
||||
@@ -79,10 +81,15 @@ export default function SeatsPage() {
|
||||
enabled: !!selectedRoute,
|
||||
});
|
||||
|
||||
const invalidateSeatData = () => {
|
||||
queryClient.refetchQueries({ queryKey: ['seatmap', selectedSchedule] });
|
||||
queryClient.refetchQueries({ queryKey: ['routeCoaches', selectedRoute] });
|
||||
};
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
setShowBlockModal(false);
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
@@ -92,14 +99,14 @@ export default function SeatsPage() {
|
||||
const unblockMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
},
|
||||
});
|
||||
|
||||
const removeSeatMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
setShowRemoveModal(false);
|
||||
setSelectedSeat(null);
|
||||
},
|
||||
@@ -108,7 +115,7 @@ export default function SeatsPage() {
|
||||
const undoRemoveMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,7 +123,7 @@ export default function SeatsPage() {
|
||||
mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) =>
|
||||
seatsApi.setMaintenance(seatId, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
setShowMaintenanceModal(false);
|
||||
setSelectedSeat(null);
|
||||
setMaintenanceReason('');
|
||||
@@ -125,7 +132,7 @@ export default function SeatsPage() {
|
||||
|
||||
const clearMaintenanceMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }),
|
||||
onSuccess: () => invalidateSeatData(),
|
||||
});
|
||||
|
||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
||||
@@ -139,7 +146,7 @@ export default function SeatsPage() {
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
setShowBlockCoachModal(false);
|
||||
setSelectedCoach(null);
|
||||
setBlockCoachReason('');
|
||||
@@ -153,7 +160,7 @@ export default function SeatsPage() {
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
invalidateSeatData();
|
||||
setShowUnblockCoachModal(false);
|
||||
setCoachToUnblock(null);
|
||||
},
|
||||
@@ -1015,7 +1022,7 @@ function SeatIcon({
|
||||
const color = getSeatColor(status);
|
||||
const canBlock = status === 'AVAILABLE';
|
||||
const canUnblock = status === 'BLOCKED';
|
||||
const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
|
||||
const canMaintenance = false;
|
||||
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
||||
|
||||
return (
|
||||
|
||||
@@ -427,6 +427,7 @@ function BookingDetailContent() {
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{!booking.isPackageBooking && booking.bookingType !== "PACKAGE" && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Fare breakdown
|
||||
@@ -481,6 +482,7 @@ function BookingDetailContent() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function PaymentPage() {
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
|
||||
@@ -47,40 +46,40 @@ export default function PaymentPage() {
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
const isPackage = !!packageName;
|
||||
|
||||
// Use the same display currency as the review page (derived from nationality)
|
||||
// Use the same display currency as the review page — stored on the schedule at search time.
|
||||
const scheduleCurrency = isRoundTrip
|
||||
? outboundSchedule?.displayCurrency
|
||||
: selectedSchedule?.displayCurrency;
|
||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||
const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const displayCurrency = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD');
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ['paymentMethods', displayCurrency],
|
||||
queryKey: ['paymentMethods'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaymentMethod[]>(`/payments/methods?currency=${displayCurrency}`);
|
||||
const response = await apiClient.get<PaymentMethod[]>(`/payments/methods`);
|
||||
return Array.isArray(response) ? response : [];
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
|
||||
|
||||
// A payment method only needs a currency conversion when its own currency differs from
|
||||
// the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB
|
||||
// total already shown on the review page is exact and there's nothing to convert.
|
||||
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
||||
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
|
||||
// Derive charge currency directly from the selected method — no separate state that can lag.
|
||||
const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
|
||||
|
||||
// Fetch the converted booking amount from the booking-amount-changer API whenever a
|
||||
// currency-specific payment method is selected.
|
||||
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
||||
const { data: bookingAmountData, isFetching: fetchingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
||||
queryKey: ['bookingAmount', bookingId, amountCurrency],
|
||||
queryFn: async () => {
|
||||
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
||||
const response: any = await apiClient.get(url);
|
||||
const response: any = await apiClient.get(`/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`);
|
||||
return response;
|
||||
},
|
||||
enabled: !!bookingId && isConversionNeeded,
|
||||
enabled: !!bookingId && !!selectedMethod,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Data is only usable when it belongs to the currently-selected method's currency.
|
||||
const dataReady = !fetchingAmount && bookingAmountData != null && bookingAmountData.currency.toUpperCase() === amountCurrency.toUpperCase();
|
||||
|
||||
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
|
||||
// split equally across both legs. This guarantees leg totals are consistent with the
|
||||
// per-passenger breakdown rows and the overall reviewed total.
|
||||
@@ -91,39 +90,33 @@ export default function PaymentPage() {
|
||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
||||
: 0;
|
||||
|
||||
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
||||
// in the booking's default currency (ETB) — they were computed and shown to the user on
|
||||
// the review page. But once a payment method with its own currency is selected (e.g.
|
||||
// Waafi/USD), the converted amount from the booking-amount API takes over so the user
|
||||
// sees the actual amount they'll be charged in that currency.
|
||||
// reviewedTotalMinor is in display-currency minor units — matches what was shown on the review page.
|
||||
// When a method with a different currency is selected, bookingAmountData gives the converted charge amount.
|
||||
// When the method's currency matches displayCurrency (or no method selected), use reviewedTotal directly.
|
||||
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
|
||||
const totalAmountDisplay = isConversionNeeded
|
||||
? (bookingAmountData != null ? bookingAmountData.amount : null)
|
||||
: (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null));
|
||||
const totalAmount = isConversionNeeded
|
||||
? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0))
|
||||
: (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0));
|
||||
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
|
||||
|
||||
// Show loading spinner while the converted amount is still in flight for a
|
||||
// currency-specific method; ETB methods always have the reviewed total instantly.
|
||||
const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null;
|
||||
// When a method is selected: show spinner until dataReady, then show converted amount.
|
||||
// When no method is selected: show the reviewed total in displayCurrency.
|
||||
const totalAmountDisplay = selectedMethod
|
||||
? (dataReady ? bookingAmountData!.amount : null)
|
||||
: (reviewedTotal != null ? reviewedTotal / 100 : null);
|
||||
const totalAmount = selectedMethod && dataReady
|
||||
? Math.round(bookingAmountData!.amount * 100)
|
||||
: (reviewedTotal ?? 0);
|
||||
const confirmedCurrency = selectedMethod
|
||||
? (dataReady ? bookingAmountData!.currency : amountCurrency)
|
||||
: displayCurrency;
|
||||
const awaitingAmount = !!selectedMethod && !dataReady;
|
||||
|
||||
useEffect(() => {
|
||||
// Once a currency-specific payment method's converted amount has loaded, that's the
|
||||
// real charge amount and currency — store it as the paid amount. Otherwise fall back
|
||||
// to the reviewed ETB total shown on the review page.
|
||||
if (isConversionNeeded && bookingAmountData != null) {
|
||||
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
||||
setPaidAmount(Math.round(bookingAmountData.amount * 100));
|
||||
} else if (reviewedTotal != null) {
|
||||
setCurrency('ETB');
|
||||
if (selectedMethod && dataReady) {
|
||||
setCurrency(bookingAmountData!.currency as 'ETB' | 'DJF' | 'USD');
|
||||
setPaidAmount(Math.round(bookingAmountData!.amount * 100));
|
||||
} else if (!selectedMethod && reviewedTotal != null) {
|
||||
setCurrency(displayCurrency as 'ETB' | 'DJF' | 'USD');
|
||||
setPaidAmount(reviewedTotal);
|
||||
} else if (bookingAmountData != null) {
|
||||
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
||||
setPaidAmount(Math.round(bookingAmountData.amount * 100));
|
||||
}
|
||||
}, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
|
||||
}, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
@@ -603,7 +596,7 @@ export default function PaymentPage() {
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
disabled={isProcessing || !method.enabled}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isSelected
|
||||
|
||||
@@ -1065,23 +1065,36 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
if (isOneWayNoOutbound) {
|
||||
const hasAlternatives = alternativeOutbound.length > 0;
|
||||
|
||||
return (
|
||||
<div className="booking-page">
|
||||
{renderClassModal()}
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-8">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains available on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}</span>.</span>
|
||||
<div className="card max-w-lg mx-auto text-center py-10 px-6 mb-8">
|
||||
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-5">
|
||||
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change date
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
No trains available
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
There are no trains scheduled on{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
|
||||
</span>
|
||||
. Try a different date to see available trains.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Change Date
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alternative Travel Options — commented out for the time being;
|
||||
only the "No trains available" banner above is shown.
|
||||
{hasAlternatives && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -1100,6 +1113,7 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1233,30 +1247,32 @@ export default function ResultsPage() {
|
||||
renderScheduleCard(schedule, true),
|
||||
)}
|
||||
</div>
|
||||
{outboundSchedules.length === 0 &&
|
||||
alternativeOutbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Alternative Outbound Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeOutbound.map((schedule: Schedule) =>
|
||||
renderScheduleCard(schedule, true, true),
|
||||
)}
|
||||
{outboundSchedules.length === 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Alternative Outbound Options — commented out for the time being;
|
||||
only the "No trains" banner above is shown.
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Alternative Outbound Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeOutbound.map((schedule: Schedule) =>
|
||||
renderScheduleCard(schedule, true, true),
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -1317,30 +1333,32 @@ export default function ResultsPage() {
|
||||
renderScheduleCard(schedule, false),
|
||||
)}
|
||||
</div>
|
||||
{inboundSchedules.length === 0 &&
|
||||
alternativeInbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Alternative Return Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeInbound.map((schedule: Schedule) =>
|
||||
renderScheduleCard(schedule, false, true),
|
||||
)}
|
||||
{inboundSchedules.length === 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Alternative Return Options — commented out for the time being;
|
||||
only the "No trains" banner above is shown.
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Alternative Return Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeInbound.map((schedule: Schedule) =>
|
||||
renderScheduleCard(schedule, false, true),
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function ReviewPage() {
|
||||
const [timeLeft, setTimeLeft] = useState<string>('');
|
||||
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
|
||||
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
|
||||
const [returnFareBreakdown, setReturnFareBreakdown] = useState<any>(null);
|
||||
const [computedTotal, setComputedTotal] = useState<number>(0);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
@@ -173,16 +174,27 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
// Prefers displayFareMinor (converted) from the fare breakdown API when available.
|
||||
// Falls back to raw ETB seat fares (which are always in minor units).
|
||||
const getPassengerSeatFare = (p: any, index?: number): number | null => {
|
||||
if (isRoundTrip) {
|
||||
// Seat-specific fares (set during seat selection) cover each leg separately — use them first.
|
||||
if (p.outboundSeatFareMinor != null || p.inboundSeatFareMinor != null) {
|
||||
if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
|
||||
return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
|
||||
}
|
||||
// No seat-specific fares: fall back to the per-leg fare-breakdown totals for both legs.
|
||||
if (!isPackageBooking && fareBreakdown?.passengers && returnFareBreakdown?.passengers && index != null) {
|
||||
const obLine = fareBreakdown.passengers[index];
|
||||
const retLine = returnFareBreakdown.passengers[index];
|
||||
const obFare = obLine?.displayFareMinor ?? obLine?.fareMinor;
|
||||
const retFare = retLine?.displayFareMinor ?? retLine?.fareMinor;
|
||||
if (obFare != null && retFare != null) return obFare + retFare;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
|
||||
const line = fareBreakdown.passengers[index];
|
||||
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
if (displayFare != null) return displayFare;
|
||||
}
|
||||
if (isRoundTrip) {
|
||||
if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
|
||||
if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
|
||||
return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
|
||||
}
|
||||
if (p.seatFareMinor == null) return null;
|
||||
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
|
||||
};
|
||||
@@ -541,6 +553,22 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
|
||||
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
|
||||
setFareBreakdown(result);
|
||||
|
||||
// For round-trips, also fetch the return leg's fare breakdown so the review page
|
||||
// can display and send the correct combined total (outbound + return per passenger).
|
||||
if (isRoundTrip && inboundSchedule) {
|
||||
const returnScheduleId = (inboundSchedule as any).id;
|
||||
const returnParams = new URLSearchParams({
|
||||
scheduleId: returnScheduleId,
|
||||
originStationId: searchCriteria.destinationStationId,
|
||||
destinationStationId: searchCriteria.originStationId,
|
||||
passengers: passengersParam,
|
||||
displayCurrency: displayCurrencyCode,
|
||||
...(searchCriteria.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
||||
});
|
||||
const returnResult: any = await apiClient.get(`/search/fare-breakdown?${returnParams}`);
|
||||
setReturnFareBreakdown(returnResult);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
})();
|
||||
@@ -566,7 +594,11 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
|
||||
if (isFreeChild) return sum;
|
||||
const seatFare = getPassengerSeatFare(p, i);
|
||||
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
// For round-trips the fallback must combine both legs; for one-way it's the single-leg fare.
|
||||
const obFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
const retLine = returnFareBreakdown?.passengers?.[i];
|
||||
const retFare = retLine?.displayFareMinor ?? retLine?.fareMinor;
|
||||
const displayFare = isRoundTrip && retFare != null ? (obFare ?? 0) + retFare : obFare;
|
||||
return sum + (seatFare ?? displayFare ?? 0);
|
||||
}, 0);
|
||||
|
||||
@@ -586,26 +618,27 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
? isPkgFreeChild(i)
|
||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||
|
||||
// Per-leg fares for round trips — use converted amounts from fareBreakdown when available
|
||||
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
// Per-leg fares for round trips — prefer seat-specific fares, then per-leg breakdowns.
|
||||
const retLine = returnFareBreakdown?.passengers?.[i];
|
||||
const obBreakdownFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
const retBreakdownFare = retLine?.displayFareMinor ?? retLine?.fareMinor;
|
||||
const outboundFare: number | null = isRoundTrip
|
||||
? (isPackageBooking
|
||||
? (packageTierPriceMinor ?? null)
|
||||
: (displayFare != null
|
||||
? Math.round(displayFare / 2)
|
||||
: ((p as any).outboundSeatFareMinor ?? null)))
|
||||
: ((p as any).outboundSeatFareMinor ?? obBreakdownFare ?? null))
|
||||
: null;
|
||||
const inboundFare: number | null = isRoundTrip
|
||||
? (isPackageBooking
|
||||
? (packageTierPriceMinor ?? null)
|
||||
: (displayFare != null
|
||||
? Math.round(displayFare / 2)
|
||||
: ((p as any).inboundSeatFareMinor ?? null)))
|
||||
: ((p as any).inboundSeatFareMinor ?? retBreakdownFare ?? null))
|
||||
: null;
|
||||
const seatFare = getPassengerSeatFare(p, i);
|
||||
const combinedDisplayFare = isRoundTrip && retBreakdownFare != null
|
||||
? (obBreakdownFare ?? 0) + retBreakdownFare
|
||||
: obBreakdownFare;
|
||||
const passengerTotal = isPackageBooking
|
||||
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
||||
: (isFreeChild ? 0 : (seatFare ?? displayFare ?? 0));
|
||||
: (isFreeChild ? 0 : (seatFare ?? combinedDisplayFare ?? 0));
|
||||
|
||||
return (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||
|
||||
@@ -52,7 +52,7 @@ const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) =>
|
||||
? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
|
||||
: bed.status === "AVAILABLE"
|
||||
? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
|
||||
: bed.status === "BOOKED"
|
||||
: bed.status === "BOOKED" || bed.status === "BLOCKED"
|
||||
? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
|
||||
: "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
|
||||
Reference in New Issue
Block a user