mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
@@ -145,7 +145,7 @@ export class CreateBookingDto {
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@IsOptional() @IsString() priceTierId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||
@ApiPropertyOptional({ description: 'Total amount in display-currency minor units as computed and displayed on the review page. When displayCurrency is ETB this equals ETB minor units; for DJF/USD it is the converted display amount. The backend uses this directly as displayTotalMinor and back-converts to ETB for storage.' })
|
||||
@IsOptional() @IsInt() reviewedTotalMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||
|
||||
@@ -837,16 +837,30 @@ export class BookingsService {
|
||||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
||||
(allFaresProvided
|
||||
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
||||
: fareCalculation.totalMinor);
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
let displayTotalMinor = resolvedTotalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
||||
// reviewedTotalMinor is now sent in display-currency minor units from the review page.
|
||||
// When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
|
||||
let resolvedTotalMinor: number;
|
||||
let displayTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
|
||||
} else {
|
||||
resolvedTotalMinor = dto.reviewedTotalMinor;
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
||||
: resolvedTotalMinor;
|
||||
} else {
|
||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
||||
: resolvedTotalMinor;
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -857,7 +871,7 @@ export class BookingsService {
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
totalMinor: resolvedTotalMinor / 100,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1011,11 +1025,14 @@ export class BookingsService {
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor) {
|
||||
totalMinor = dto.reviewedTotalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
|
||||
} else {
|
||||
totalMinor = dto.reviewedTotalMinor;
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
}
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
|
||||
@@ -270,7 +270,7 @@ function BookingsPageContent() {
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
|
||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { UserPlus, ChevronLeft } from 'lucide-react';
|
||||
import { UserPlus, LogIn, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -95,7 +95,6 @@ export default function AuthCheckPage() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* TODO: re-enable once auth is integrated
|
||||
<Tooltip content={[
|
||||
'Saved passenger details',
|
||||
'View booking history',
|
||||
@@ -109,7 +108,6 @@ export default function AuthCheckPage() {
|
||||
SignIn or Register
|
||||
</button>
|
||||
</Tooltip>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
|
||||
@@ -126,7 +126,10 @@ export default function ConfirmationPage() {
|
||||
const settledAmountMinor = _booking?.payment?.amountMinor;
|
||||
const settledCurrency = _booking?.payment?.currency;
|
||||
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||
const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB";
|
||||
// Derive display currency from nationality (same logic as review/payment pages)
|
||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||
const passengerDisplayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const voucherCurrency = hasSettledAmount ? settledCurrency! : passengerDisplayCurrency;
|
||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||
const status = _booking?.status || "CONFIRMED";
|
||||
|
||||
@@ -530,15 +533,15 @@ export default function ConfirmationPage() {
|
||||
// The server-confirmed settled amount is authoritative — prefer it over
|
||||
// any client-side session state, which can go stale (e.g. after a refresh).
|
||||
if (_booking?.payment?.amountMinor != null) {
|
||||
return `${_booking.payment.currency || "ETB"} ${_booking.payment.amountMinor}`;
|
||||
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
|
||||
}
|
||||
if (reviewedTotalMinor != null)
|
||||
return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||
return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||
if (paidAmountMinor != null)
|
||||
return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||
return `${paidCurrency || 'ETB'} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||
if (_booking?.totalMinor != null)
|
||||
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||
return "ETB 0.00";
|
||||
return 'ETB 0.00';
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,11 @@ import {
|
||||
} from "@/utils/manage-booking-return";
|
||||
import QRCode from "qrcode.react";
|
||||
|
||||
// Same convention as /booking/payment — payment methods are ETB-settled by default;
|
||||
// a method only needs a currency conversion when its own currency differs.
|
||||
const displayCurrency = "ETB" as const;
|
||||
// Derive display currency from the booking record's own displayCurrency field
|
||||
// (set at booking creation from the passenger's nationality). Falls back to ETB.
|
||||
function getBookingDisplayCurrency(booking: any): string {
|
||||
return booking?.displayCurrency || 'ETB';
|
||||
}
|
||||
|
||||
const getIconForMethod = (methodType: string) => {
|
||||
if (methodType.includes("CARD")) return CreditCard;
|
||||
@@ -126,8 +128,10 @@ function BookingDetailContent() {
|
||||
const selectedPaymentMethod =
|
||||
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
||||
|
||||
const displayCurrency = getBookingDisplayCurrency(booking);
|
||||
|
||||
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
|
||||
// when the selected method actually settles in a different currency than ETB.
|
||||
// when the selected method actually settles in a different currency than the booking's display currency.
|
||||
const isConversionNeeded =
|
||||
!!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
||||
const amountCurrency = isConversionNeeded
|
||||
@@ -152,7 +156,7 @@ function BookingDetailContent() {
|
||||
? bookingAmountData != null
|
||||
? bookingAmountData.amount
|
||||
: null
|
||||
: (booking?.totalMinor ?? 0) / 100;
|
||||
: (booking?.displayTotalMinor ?? booking?.totalMinor ?? 0) / 100;
|
||||
const confirmedCurrency = isConversionNeeded
|
||||
? bookingAmountData?.currency || amountCurrency
|
||||
: displayCurrency;
|
||||
@@ -399,6 +403,15 @@ function BookingDetailContent() {
|
||||
}));
|
||||
})();
|
||||
|
||||
// Scale per-passenger ETB fareMinor to the booking's display currency using the
|
||||
// ratio of displayTotalMinor / totalMinor. Falls back to 1 (ETB) when not available.
|
||||
const fareScaleFactor = (() => {
|
||||
const etbTotal = booking?.totalMinor;
|
||||
const displayTotal = booking?.displayTotalMinor;
|
||||
if (!etbTotal || !displayTotal || etbTotal === displayTotal) return 1;
|
||||
return displayTotal / etbTotal;
|
||||
})();
|
||||
|
||||
// Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per
|
||||
// passenger, Total with a loading spinner while a currency conversion is in flight, and
|
||||
// a note confirming what will actually be charged once a payment method is selected.
|
||||
@@ -439,7 +452,7 @@ function BookingDetailContent() {
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatFare(passenger.fareMinor ?? 0, displayCurrency)}
|
||||
{formatFare(Math.round((passenger.fareMinor ?? 0) * fareScaleFactor), displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
{isRoundTripBooking && !isFreeChild && (
|
||||
@@ -448,7 +461,7 @@ function BookingDetailContent() {
|
||||
<span>Outbound</span>
|
||||
<span>
|
||||
{formatFare(
|
||||
passenger.outboundFareMinor ?? 0,
|
||||
Math.round((passenger.outboundFareMinor ?? 0) * fareScaleFactor),
|
||||
displayCurrency,
|
||||
)}
|
||||
</span>
|
||||
@@ -457,7 +470,7 @@ function BookingDetailContent() {
|
||||
<span>Return</span>
|
||||
<span>
|
||||
{formatFare(
|
||||
passenger.returnFareMinor ?? 0,
|
||||
Math.round((passenger.returnFareMinor ?? 0) * fareScaleFactor),
|
||||
displayCurrency,
|
||||
)}
|
||||
</span>
|
||||
@@ -937,7 +950,7 @@ function BookingDetailContent() {
|
||||
Total paid:{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{booking?.payment?.amountMinor != null
|
||||
? `${booking.payment.currency || "ETB"} ${booking.payment.amountMinor}`
|
||||
? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
|
||||
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
{booking?.payment?.method && (
|
||||
|
||||
@@ -15,6 +15,8 @@ interface BookingListItem {
|
||||
status: string;
|
||||
totalMinor: number;
|
||||
currency: string;
|
||||
displayCurrency?: string | null;
|
||||
displayTotalMinor?: number | null;
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
bookingType: string;
|
||||
@@ -200,7 +202,8 @@ export default function BookingLookupPage() {
|
||||
</p>
|
||||
{phoneResults.map((b) => {
|
||||
const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" };
|
||||
const amountEtb = (b.totalMinor / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
|
||||
const displayCurrency = b.displayCurrency || 'ETB';
|
||||
const displayAmount = ((b.displayTotalMinor ?? b.totalMinor) / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
@@ -229,7 +232,7 @@ export default function BookingLookupPage() {
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{amountEtb} ETB
|
||||
{displayAmount} {displayCurrency}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-[rgb(20,113,76)] transition-colors" />
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,9 @@ export default function PaymentPage() {
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
const isPackage = !!packageName;
|
||||
|
||||
const displayCurrency = 'ETB' as const;
|
||||
// Use the same display currency as the review page (derived from nationality)
|
||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||
const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ['paymentMethods', displayCurrency],
|
||||
@@ -314,7 +316,7 @@ export default function PaymentPage() {
|
||||
{fare !== undefined && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800 flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">{label} fare</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{displayCurrency} {(fare / 100).toFixed(2)}</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{confirmedCurrency} {(fare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { format } from "date-fns";
|
||||
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
||||
import { formatFare } from "@/utils/fare-utils";
|
||||
import { useCurrencySymbol } from "@/lib/useCurrencies";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function ResultsPage() {
|
||||
@@ -97,7 +96,6 @@ export default function ResultsPage() {
|
||||
|
||||
const nat = (searchData.nationality ?? '').toUpperCase();
|
||||
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("origin")) {
|
||||
@@ -419,7 +417,7 @@ export default function ResultsPage() {
|
||||
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
|
||||
)
|
||||
: 0;
|
||||
const coachCurrency = displayCurrencySymbol;
|
||||
const coachCurrency = displayCurrencyCode;
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
const selectThisCoach = () =>
|
||||
@@ -540,7 +538,7 @@ export default function ResultsPage() {
|
||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium ml-1">
|
||||
{coachCurrency}
|
||||
</span>
|
||||
</div>
|
||||
@@ -616,7 +614,7 @@ export default function ResultsPage() {
|
||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||
let lowestFare = null;
|
||||
const displayCurrency = displayCurrencySymbol;
|
||||
const displayCurrency = displayCurrencyCode;
|
||||
if (schedule.coachTypes?.length) {
|
||||
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
|
||||
const allFares = allClasses
|
||||
|
||||
@@ -7,10 +7,9 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
import { useCurrencySymbol } from '@/lib/useCurrencies';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
function getPassengerIdFromToken(token: string): string | null {
|
||||
@@ -60,7 +59,6 @@ export default function ReviewPage() {
|
||||
// Derive display currency from nationality so fares show in the passenger's home currency.
|
||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
@@ -152,7 +150,8 @@ export default function ReviewPage() {
|
||||
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
|
||||
|
||||
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
|
||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||
|
||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
|
||||
const pkgChildFare = pkgAdultFare;
|
||||
|
||||
@@ -166,7 +165,15 @@ export default function ReviewPage() {
|
||||
return childIndex < adultPassengerCount;
|
||||
};
|
||||
|
||||
const getPassengerSeatFare = (p: any): number | null => {
|
||||
// Returns the fare for a passenger in the display currency.
|
||||
// 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 (!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;
|
||||
@@ -445,13 +452,14 @@ export default function ReviewPage() {
|
||||
const isFreeChild = isPackageBooking
|
||||
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
|
||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
const seatFare = getPassengerSeatFare(p, i);
|
||||
const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
|
||||
const fareMinor = isPackageBooking
|
||||
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
||||
const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined;
|
||||
const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined;
|
||||
const halfFare = seatFare != null ? Math.round(seatFare / 2) : undefined;
|
||||
const outboundFareMinor = isRoundTrip ? (halfFare ?? (p as any).outboundSeatFareMinor ?? undefined) : undefined;
|
||||
const inboundFareMinor = isRoundTrip ? (halfFare ?? (p as any).inboundSeatFareMinor ?? undefined) : undefined;
|
||||
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
|
||||
});
|
||||
setReviewedTotal(computedTotal, passengerFares);
|
||||
@@ -482,55 +490,60 @@ export default function ReviewPage() {
|
||||
}
|
||||
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
|
||||
|
||||
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
|
||||
// Package bookings use the stored tier price — no fare calculation needed
|
||||
if (isPackageBooking) return;
|
||||
|
||||
try {
|
||||
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
||||
const scheduleSeatClassName = isRoundTrip
|
||||
? (outboundSchedule as any)?.seatClassName
|
||||
: (selectedSchedule as any)?.seatClassName;
|
||||
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
|
||||
if (!fallbackSeatClassId) return;
|
||||
|
||||
const resolveSeatClassId = (p: any): string => {
|
||||
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
|
||||
if (!bedPosition) return fallbackSeatClassId;
|
||||
const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
|
||||
return match?.id || fallbackSeatClassId;
|
||||
};
|
||||
|
||||
const passengersParam = JSON.stringify(
|
||||
passengers.map(p => ({
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
seatClassId: resolveSeatClassId(p),
|
||||
nationality: p.nationality,
|
||||
}))
|
||||
);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
scheduleId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
passengers: passengersParam,
|
||||
displayCurrency: displayCurrencyCode,
|
||||
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
||||
});
|
||||
|
||||
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
|
||||
setFareBreakdown(result);
|
||||
} catch (err) {
|
||||
}
|
||||
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
|
||||
const fareBreakdownFetchedRef = useRef(false);
|
||||
const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (fareBreakdownFetchedRef.current) return;
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
||||
const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
|
||||
if (!scheduleId) return;
|
||||
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
|
||||
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
|
||||
if (isPackageBooking) return;
|
||||
|
||||
fareBreakdownFetchedRef.current = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
||||
const scheduleSeatClassName = isRoundTrip
|
||||
? (outboundSchedule as any)?.seatClassName
|
||||
: (selectedSchedule as any)?.seatClassName;
|
||||
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
|
||||
if (!fallbackSeatClassId) return;
|
||||
|
||||
const resolveSeatClassId = (p: any): string => {
|
||||
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
|
||||
if (!bedPosition) return fallbackSeatClassId;
|
||||
const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
|
||||
return match?.id || fallbackSeatClassId;
|
||||
};
|
||||
|
||||
const passengersParam = JSON.stringify(
|
||||
passengers.map(p => ({
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
seatClassId: resolveSeatClassId(p),
|
||||
nationality: p.nationality,
|
||||
}))
|
||||
);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
scheduleId,
|
||||
originStationId: searchCriteria.originStationId,
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
passengers: passengersParam,
|
||||
displayCurrency: displayCurrencyCode,
|
||||
...(searchCriteria.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
||||
});
|
||||
|
||||
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
|
||||
setFareBreakdown(result);
|
||||
} catch (err) {
|
||||
}
|
||||
})();
|
||||
// Intentionally re-runs until store is hydrated (scheduleId/originStationId become
|
||||
// available), then the ref guard ensures it only fetches once.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchCriteria?.originStationId, searchCriteria?.destinationStationId, scheduleId]);
|
||||
|
||||
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
|
||||
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
|
||||
@@ -539,7 +552,7 @@ export default function ReviewPage() {
|
||||
const isChild_ = isPackageChild(i);
|
||||
const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount;
|
||||
if (isFreeChild) return sum;
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
const seatFare = getPassengerSeatFare(p, i);
|
||||
const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare;
|
||||
return sum + (seatFare ?? pkgFallback);
|
||||
}, 0)
|
||||
@@ -548,8 +561,9 @@ export default function ReviewPage() {
|
||||
const line = fareBreakdown?.passengers?.[i];
|
||||
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
|
||||
if (isFreeChild) return sum;
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
return sum + (seatFare ?? line?.fareMinor ?? 0);
|
||||
const seatFare = getPassengerSeatFare(p, i);
|
||||
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
return sum + (seatFare ?? displayFare ?? 0);
|
||||
}, 0);
|
||||
|
||||
// Keep computedTotal in sync so handleConfirm can persist it to the store
|
||||
@@ -568,17 +582,26 @@ export default function ReviewPage() {
|
||||
? isPkgFreeChild(i)
|
||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||
|
||||
// Per-leg fares for round trips
|
||||
// Per-leg fares for round trips — use converted amounts from fareBreakdown when available
|
||||
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
|
||||
const outboundFare: number | null = isRoundTrip
|
||||
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null))
|
||||
? (isPackageBooking
|
||||
? (packageTierPriceMinor ?? null)
|
||||
: (displayFare != null
|
||||
? Math.round(displayFare / 2)
|
||||
: ((p as any).outboundSeatFareMinor ?? null)))
|
||||
: null;
|
||||
const inboundFare: number | null = isRoundTrip
|
||||
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null))
|
||||
? (isPackageBooking
|
||||
? (packageTierPriceMinor ?? null)
|
||||
: (displayFare != null
|
||||
? Math.round(displayFare / 2)
|
||||
: ((p as any).inboundSeatFareMinor ?? null)))
|
||||
: null;
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
const seatFare = getPassengerSeatFare(p, i);
|
||||
const passengerTotal = isPackageBooking
|
||||
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
||||
: (isFreeChild ? 0 : (seatFare ?? displayFare ?? 0));
|
||||
|
||||
return (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||
@@ -594,7 +617,7 @@ export default function ReviewPage() {
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatFare(passengerTotal, displayCurrencySymbol)}
|
||||
{formatFare(passengerTotal, displayCurrencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Round-trip: show outbound + inbound breakdown */}
|
||||
@@ -602,11 +625,11 @@ export default function ReviewPage() {
|
||||
<div className="mt-1 space-y-0.5 pl-2">
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>↗ Outbound</span>
|
||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}</span>
|
||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencyCode) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>↙ Return</span>
|
||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}</span>
|
||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencyCode) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -615,7 +638,7 @@ export default function ReviewPage() {
|
||||
})}
|
||||
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
||||
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencyCode)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons — visible only in desktop sidebar */}
|
||||
@@ -963,7 +986,7 @@ export default function ReviewPage() {
|
||||
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
||||
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencyCode)}</span>
|
||||
</div>
|
||||
{createBookingMutation.isError && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
||||
|
||||
@@ -415,7 +415,7 @@ export default function SeatsPage() {
|
||||
if (!coachTypeId) return null;
|
||||
const types = (currentSchedule as any)?.coachTypes || [];
|
||||
const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId);
|
||||
const fares = (match?.classes || []).map((c: any) => c.baseFareMinor).filter((f: number) => f > 0);
|
||||
const fares = (match?.classes || []).map((c: any) => c.displayAmountMinor ?? c.baseFareMinor).filter((f: number) => f > 0);
|
||||
return fares.length ? Math.min(...fares) : null;
|
||||
};
|
||||
|
||||
@@ -437,10 +437,10 @@ export default function SeatsPage() {
|
||||
const match = currentCoachTypeClasses.find((c: any) =>
|
||||
c.name?.toLowerCase().includes(seat.bedPosition),
|
||||
);
|
||||
if (match) return match.baseFareMinor;
|
||||
if (match) return match.displayAmountMinor ?? match.baseFareMinor;
|
||||
}
|
||||
const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
|
||||
return (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
|
||||
return (regular || currentCoachTypeClasses[0])?.displayAmountMinor ?? (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
|
||||
},
|
||||
[currentCoachTypeClasses],
|
||||
);
|
||||
@@ -573,7 +573,7 @@ export default function SeatsPage() {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Fare Will Change",
|
||||
message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
|
||||
message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ${currentSchedule?.displayCurrency || 'ETB'} ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
|
||||
type: "warning",
|
||||
showCancel: true,
|
||||
confirmText: "Switch Coach",
|
||||
@@ -588,7 +588,7 @@ export default function SeatsPage() {
|
||||
isOpen: true,
|
||||
title: "Switch Coach Type",
|
||||
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
|
||||
newFare != null ? ` Fare: ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
|
||||
newFare != null ? ` Fare: ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
|
||||
}`,
|
||||
type: "info",
|
||||
showCancel: true,
|
||||
@@ -926,7 +926,7 @@ export default function SeatsPage() {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Fare Will Change",
|
||||
message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (ETB ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
|
||||
message: `${positionLabel} ${seatLabel} costs ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (${currentSchedule?.displayCurrency || 'ETB'} ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
|
||||
type: "warning",
|
||||
showCancel: true,
|
||||
confirmText: "Continue",
|
||||
@@ -1976,6 +1976,11 @@ export default function SeatsPage() {
|
||||
>
|
||||
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
|
||||
</span>
|
||||
{assignedSeat && seatFare != null && (
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{currentSchedule?.displayCurrency || 'ETB'} {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -192,9 +192,7 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// TODO: Sign in / Register temporarily disabled — re-enable later.
|
||||
null
|
||||
/* <div className="flex items-center gap-2 px-1 pt-1">
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
@@ -207,7 +205,7 @@ export default function AppSidebar() {
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div> */
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
// NOTE: User icon + useAuthStore are unused while the Sign in / Account tab is
|
||||
// temporarily disabled below. Re-add them when that tab is restored.
|
||||
import { Home, Phone, Ticket } from 'lucide-react';
|
||||
import { Home, Phone, Ticket, User } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
// The linear, one-screen-at-a-time booking flow — each of these pages already
|
||||
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
|
||||
@@ -22,6 +21,7 @@ const LINEAR_FLOW_PREFIXES = [
|
||||
|
||||
export default function BottomTabBar() {
|
||||
const pathname = usePathname() ?? '';
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
|
||||
if (isInLinearFlow) return null;
|
||||
@@ -30,13 +30,12 @@ export default function BottomTabBar() {
|
||||
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
||||
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
|
||||
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
||||
// TODO: Sign in / Account tab temporarily disabled — re-enable later.
|
||||
// {
|
||||
// href: isAuthenticated ? '/profile' : '/login',
|
||||
// label: isAuthenticated ? 'Account' : 'Sign in',
|
||||
// icon: User,
|
||||
// match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
||||
// },
|
||||
{
|
||||
href: isAuthenticated ? '/profile' : '/login',
|
||||
label: isAuthenticated ? 'Account' : 'Sign in',
|
||||
icon: User,
|
||||
match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -452,7 +452,9 @@ interface VoucherData {
|
||||
schedule: VoucherSchedule;
|
||||
returnSchedule?: VoucherSchedule | null;
|
||||
totalMinor: number;
|
||||
currency: string;
|
||||
displayTotalMinor?: number;
|
||||
currency?: string;
|
||||
displayCurrency?: string;
|
||||
bookingType: string;
|
||||
createdAt: string;
|
||||
// One ticket per passenger per leg (round trips have a separate ticket/barcode for the
|
||||
@@ -473,7 +475,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
const settledAmountMinor = booking.payment?.amountMinor;
|
||||
const settledCurrency = booking.payment?.currency;
|
||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
|
||||
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
||||
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||
|
||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||
|
||||
@@ -519,7 +524,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
|
||||
outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
|
||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||
fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor,
|
||||
fareMinor: voucherFareMinor,
|
||||
currency: voucherCurrency,
|
||||
fareIsMajorUnits: useSettledAmount,
|
||||
createdAt: booking.createdAt,
|
||||
|
||||
Reference in New Issue
Block a user