Merge pull request #684 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-07-14 21:16:41 +03:00
committed by GitHub
14 changed files with 196 additions and 132 deletions

View File

@@ -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)' })

View File

@@ -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) {

View File

@@ -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>
),
},

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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,