diff --git a/apps/edr-passenger-api/prisma/migrations/20260710060414_add_booking_origin_destination_stations/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260710060414_add_booking_origin_destination_stations/migration.sql
new file mode 100644
index 000000000..1e3087e58
--- /dev/null
+++ b/apps/edr-passenger-api/prisma/migrations/20260710060414_add_booking_origin_destination_stations/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "Booking" ADD COLUMN "destinationStationId" TEXT,
+ADD COLUMN "originStationId" TEXT;
diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma
index 8a4224e73..73387fb18 100644
--- a/apps/edr-passenger-api/prisma/schema.prisma
+++ b/apps/edr-passenger-api/prisma/schema.prisma
@@ -537,6 +537,8 @@ model Booking {
returnLeg2OriginStationId String?
returnLeg2DestStationId String?
returnLeg2SeatClassId String?
+ originStationId String?
+ destinationStationId String?
outboundBoardedAt DateTime?
returnBoardedAt DateTime?
contactEmail String?
diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
index 2b15cdc7c..2d20b4503 100644
--- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
+++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts
@@ -24,6 +24,14 @@ type IamUserRow = {
verified_by: string | null;
};
+function resolvePreferredCurrency(nationality: string | null | undefined, faydaVerified: boolean): string {
+ if (faydaVerified) return 'ETB';
+ const n = (nationality ?? '').toLowerCase();
+ if (n.includes('ethiopi')) return 'ETB';
+ if (n.includes('djibout')) return 'DJF';
+ return 'USD';
+}
+
@Injectable()
export class PassengerAuthService {
private readonly logger = new Logger(PassengerAuthService.name);
@@ -250,6 +258,8 @@ export class PassengerAuthService {
if (!passenger) throw new Error('Passenger not found');
const iam = iamRows[0];
+ const faydaVerified = iam?.verified_by === 'fayda';
+ const nationality = iam?.metadata?.nationality ?? null;
return {
iamUserId,
@@ -259,7 +269,9 @@ export class PassengerAuthService {
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
- faydaVerified: iam?.verified_by === 'fayda',
+ nationality,
+ faydaVerified,
+ preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
createdAt: passenger.createdAt,
passenger: {
id: passenger.id,
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index b784ea111..9c9384850 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
+import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
function generateRef(): string {
@@ -396,7 +397,7 @@ export class BookingsService {
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
- schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
package: { select: { id: true, name: true, code: true } },
@@ -453,13 +454,18 @@ export class BookingsService {
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
+ originStationId: (booking as any).originStationId ?? null,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: {
train: booking.schedule.train,
- originStation: booking.schedule.originStation,
- destinationStation: booking.schedule.destinationStation,
+ originStation: (booking as any).originStationId
+ ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
+ : booking.schedule.originStation,
+ destinationStation: (booking as any).destinationStationId
+ ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
+ : booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
@@ -547,7 +553,7 @@ export class BookingsService {
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
- const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
@@ -589,6 +595,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor / 100,
@@ -705,7 +713,7 @@ export class BookingsService {
}
const taxesMinor = 0;
- const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
@@ -759,6 +767,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
@@ -901,7 +911,7 @@ export class BookingsService {
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
- const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
@@ -943,6 +953,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.transitStationId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
@@ -1095,7 +1107,7 @@ export class BookingsService {
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
- const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(nat);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
@@ -1146,6 +1158,8 @@ export class BookingsService {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index fb218e052..b3e7fbf3e 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -249,6 +249,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
totalMinor: resolvedTotalMinor,
adultCount,
@@ -506,6 +508,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
@@ -707,6 +711,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
@@ -921,6 +927,8 @@ export class GuestBookingService {
bookingRef: generateRef(),
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.returnLeg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts
index b2708af5a..99fee3cdf 100644
--- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts
+++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts
@@ -16,7 +16,19 @@ export class CurrenciesService {
orderBy: { toCurrency: 'asc' },
});
- return rates.map(rate => ({
+ const base = {
+ id: 'etb-base',
+ code: 'ETB',
+ name: 'Ethiopian Birr',
+ symbol: 'Br',
+ baseCurrencyCode: 'ETB',
+ exchangeRate: 1,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ return [base, ...rates.map(rate => ({
id: rate.id,
code: rate.toCurrency,
name: this.getCurrencyName(rate.toCurrency),
@@ -26,7 +38,7 @@ export class CurrenciesService {
isActive: true,
createdAt: rate.createdAt,
updatedAt: rate.createdAt,
- }));
+ }))];
}
async createCurrency(dto: CreateCurrencyDto) {
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index a28be295e..7cbc6d881 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -491,7 +491,7 @@ export class SearchService {
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
- const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
+ const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
index 1642f0a14..65960b4d6 100644
--- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
@@ -190,7 +190,10 @@ function BookingsPageContent() {
render: (booking: any) => {
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnDeparture = booking?.returnSchedule?.departureAt;
- console.log(JSON.stringify(booking.packageId));
+ const hasActualStops = booking.schedule?.originStation && booking.schedule?.destinationStation;
+ const isFullRoute =
+ !booking.originStationId &&
+ booking.schedule?.originStation?.id === booking.schedule?.fullOriginStationId;
return (
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
index 7cc9875dc..3c10de561 100644
--- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
@@ -148,13 +148,6 @@ export default function ClassesPage() {
{(cls.baseFareMinor / 100).toFixed(2)} ETB
),
},
- {
- key: 'premiumMinor',
- label: 'Premium',
- render: (cls: any) => (
- {cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB
- ),
- },
{
key: 'insuranceFeeMinor',
label: 'Insurance',
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
index ad10052fd..30d736e37 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
@@ -108,6 +108,7 @@ export default function TariffRatesPage() {
nationalityType: selectedNationalityType,
bedPosition: selectedBedPosition || null,
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
+ insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0,
isActive: fd.get('isActive') === 'true',
};
if (editingClass) {
@@ -136,6 +137,9 @@ export default function TariffRatesPage() {
c.bedPosition?.toLowerCase().includes(s) ||
c.coachType?.name?.toLowerCase().includes(s)
);
+ }).sort((a: any, b: any) => {
+ if (a.nationalityType === b.nationalityType) return 0;
+ return a.nationalityType === 'LOCAL' ? -1 : 1;
});
const suggestName = () => {
@@ -171,12 +175,6 @@ export default function TariffRatesPage() {
return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId};
},
},
- {
- key: 'bedPosition', label: 'Bed Position',
- render: (c: any) => c.bedPosition
- ? {c.bedPosition}
- : Standard,
- },
{
key: 'name', label: 'Class Name',
render: (c: any) => {c.name},
@@ -200,6 +198,12 @@ export default function TariffRatesPage() {
);
},
},
+ {
+ key: 'insuranceFeeMinor', label: 'Insurance Fee',
+ render: (c: any) => (
+ {c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB
+ ),
+ },
{
key: 'isActive', label: 'Status',
render: (c: any) => (
@@ -241,7 +245,7 @@ export default function TariffRatesPage() {
setSearch(e.target.value)}
@@ -395,6 +399,20 @@ export default function TariffRatesPage() {
)}
+
+
+
+
Flat fee per passenger (e.g., travel insurance)
+
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index fa52aad32..183e8af01 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -22,6 +22,8 @@ import {
} from "lucide-react";
import { format } from "date-fns";
import { formatTime, getTimePeriod } from "@/utils/format";
+import { formatFare } from "@/utils/fare-utils";
+import { useCurrencySymbol } from "@/lib/useCurrencies";
import { useState, useEffect } from "react";
export default function ResultsPage() {
@@ -35,6 +37,8 @@ export default function ResultsPage() {
const [outboundScheduleData, setOutboundScheduleData] = useState
(
() => useBookingStore.getState().outboundSchedule,
);
+ const [effectiveDepartureDate, setEffectiveDepartureDate] = useState('');
+ const [effectiveReturnDate, setEffectiveReturnDate] = useState('');
const [classModal, setClassModal] = useState(null);
const [promoData, setPromoData] = useState<{
code: string;
@@ -84,6 +88,17 @@ export default function ResultsPage() {
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
};
+ // Initialise effective dates from URL/store once searchData is stable
+ useEffect(() => {
+ if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
+ if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchData.date, searchData.returnDate]);
+
+ const nat = (searchData.nationality ?? '').toUpperCase();
+ const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
+ const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
+
useEffect(() => {
if (searchParams.get("origin")) {
setSearchCriteria({
@@ -249,7 +264,7 @@ export default function ResultsPage() {
const minFare = coachType?.classes.length
? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
: 0;
- const fareCurrency = "ETB";
+ const fareCurrency = displayCurrencyCode;
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
@@ -281,10 +296,21 @@ export default function ResultsPage() {
coachTypes: schedule.coachTypes || [],
};
+ // Extract the actual date from the schedule (YYYY-MM-DD)
+ const scheduleDate = schedule.departureAt
+ ? schedule.departureAt.slice(0, 10)
+ : null;
+
// For round trip, store outbound and advance to inbound step
if (isRoundTrip && isOutbound) {
setOutboundScheduleData(scheduleData);
setOutboundSchedule(scheduleData);
+ if (scheduleDate) {
+ setEffectiveDepartureDate(scheduleDate);
+ if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
+ setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
+ }
+ }
setClassModal(null);
setRoundTripStep("inbound");
window.scrollTo({ top: 0, behavior: "smooth" });
@@ -293,6 +319,12 @@ export default function ResultsPage() {
// For round trip inbound, proceed with both schedules
if (isRoundTrip && !isOutbound) {
+ if (scheduleDate) {
+ setEffectiveReturnDate(scheduleDate);
+ if (searchCriteria && scheduleDate !== searchCriteria.returnDate) {
+ setSearchCriteria({ ...searchCriteria, returnDate: scheduleDate });
+ }
+ }
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
// return seat selection page shows the same prices as the outbound leg.
const inboundScheduleData = outboundScheduleData
@@ -307,6 +339,12 @@ export default function ResultsPage() {
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
} else {
// For one-way
+ if (scheduleDate) {
+ setEffectiveDepartureDate(scheduleDate);
+ if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
+ setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
+ }
+ }
setSelectedSchedule(scheduleData);
}
@@ -378,10 +416,10 @@ export default function ResultsPage() {
selectedCoachType?.id === coachType.coachTypeId;
const minPrice = coachType.classes.length
? Math.min(
- ...coachType.classes.map((c: any) => c.baseFareMinor),
+ ...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
)
: 0;
- const coachCurrency = "ETB";
+ const coachCurrency = displayCurrencySymbol;
const CoachIcon = getCoachIcon(coachType.coachTypeName);
const selectThisCoach = () =>
@@ -468,10 +506,7 @@ export default function ResultsPage() {
: "text-gray-900 dark:text-white"
}`}
>
- {(minPrice / 100).toFixed(2)}
-
-
- {coachCurrency}
+ {formatFare(minPrice, coachCurrency)}
@@ -503,7 +538,7 @@ export default function ResultsPage() {
- {(cls.baseFareMinor / 100).toFixed(2)}
+ {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
{coachCurrency}
@@ -581,11 +616,11 @@ 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 = "ETB";
+ const displayCurrency = displayCurrencySymbol;
if (schedule.coachTypes?.length) {
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
const allFares = allClasses
- .map((c) => c.baseFareMinor)
+ .map((c) => c.displayAmountMinor ?? c.baseFareMinor)
.filter((f) => f > 0);
lowestFare = allFares.length ? Math.min(...allFares) : null;
} else if (schedule.faresByClass?.length) {
@@ -715,9 +750,7 @@ export default function ResultsPage() {
Starting from
- {lowestFare
- ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}`
- : "N/A"}
+ {lowestFare ? formatFare(lowestFare, displayCurrency) : "N/A"}
per adult
@@ -1098,8 +1131,8 @@ export default function ResultsPage() {
- {searchData.date
- ? format(new Date(searchData.date), "EEEE, MMMM d, yyyy")
+ {effectiveDepartureDate
+ ? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: "Date not specified"}
@@ -1129,11 +1162,8 @@ export default function ResultsPage() {
Select Outbound Journey
- {searchData.date
- ? format(
- new Date(searchData.date),
- "EEEE, MMMM d, yyyy",
- )
+ {effectiveDepartureDate
+ ? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: ""}
@@ -1182,6 +1212,9 @@ export default function ResultsPage() {
{outboundScheduleData.origin} →{" "}
{outboundScheduleData.destination}
+ {outboundScheduleData.departureTime
+ ? ` · ${format(new Date(outboundScheduleData.departureTime), "EEE, MMM d, yyyy")}`
+ : ""}
{outboundScheduleData.selectedSeatClassName
? ` · ${outboundScheduleData.selectedSeatClassName}`
: ""}
@@ -1213,11 +1246,8 @@ export default function ResultsPage() {
Select Return Journey
- {searchData.returnDate
- ? format(
- new Date(searchData.returnDate),
- "EEEE, MMMM d, yyyy",
- )
+ {effectiveReturnDate
+ ? format(new Date(`${effectiveReturnDate}T00:00:00`), "EEEE, MMMM d, yyyy")
: ""}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 57b098dc1..5f4b41c72 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -10,6 +10,7 @@ import { formatTime, getTimePeriod } from '@/utils/format';
import { useState, useEffect, useCallback } 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 {
@@ -56,9 +57,10 @@ export default function ReviewPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
- // Prefer the currency already stored on the selected schedule (set from search results).
- // Fall back to deriving from nationality so the review page is never left with a stale value.
- const displayCurrency = 'ETB';
+ // 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;
@@ -329,7 +331,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
- displayCurrency: displayCurrency,
+ displayCurrency: displayCurrencyCode,
passengers: bookingPassengers.map((p) => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
@@ -386,7 +388,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
- displayCurrency: displayCurrency,
+ displayCurrency: displayCurrencyCode,
passengers: guestBookingPassengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
@@ -510,7 +512,7 @@ export default function ReviewPage() {
originStationId,
destinationStationId,
passengers: passengersParam,
- displayCurrency,
+ displayCurrency: displayCurrencyCode,
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
});
@@ -518,7 +520,7 @@ export default function ReviewPage() {
setFareBreakdown(result);
} catch (err) {
}
- }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
+ }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -589,7 +591,7 @@ export default function ReviewPage() {
)}
- {formatFare(passengerTotal, displayCurrency)}
+ {formatFare(passengerTotal, displayCurrencySymbol)}
{/* Round-trip: show outbound + inbound breakdown */}
@@ -597,11 +599,11 @@ export default function ReviewPage() {
↗ Outbound
- {outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}
+ {outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}
↙ Return
- {inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}
+ {inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}
)}
@@ -610,7 +612,7 @@ export default function ReviewPage() {
})}
Total
- {displayCurrency} {(total / 100).toFixed(2)}
+ {formatFare(total, displayCurrencySymbol)}
{/* Action buttons — visible only in desktop sidebar */}
@@ -886,49 +888,51 @@ export default function ReviewPage() {
{passengers.map((p, i) => (
-
-
-
{p.name}
+
+ {/* Left — passenger info */}
+
+
{p.name}
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
+ {/* Right — seat details */}
+ {isRoundTrip ? (
+
+
+
Outbound
+
+ {(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — }
+ {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
+
+ {(p as any).outboundSeatId && (
+
{formatSeatClass(outboundSchedule)}
+ )}
+
+
+
Return
+
+ {(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} — }
+ {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
+
+ {(p as any).inboundSeatId && (
+
{formatSeatClass(inboundSchedule)}
+ )}
+
+
+ ) : (
+
+
Seat
+
+ {p.coachNumber && {p.coachNumber} — }
+ {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
+
+ {p.seatId && (
+
{formatSeatClass(selectedSchedule)}
+ )}
+
+ )}
- {isRoundTrip ? (
-
-
-
Outbound Seat
-
- {(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — }
- {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
-
- {(p as any).outboundSeatId && (
-
{formatSeatClass(outboundSchedule)}
- )}
-
-
-
Return Seat
-
- {(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} —}
- {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
-
- {(p as any).inboundSeatId && (
-
{formatSeatClass(inboundSchedule)}
- )}
-
-
- ) : (
-
-
Seat
-
- {p.coachNumber && {p.coachNumber} — }
- {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
-
- {p.seatId && (
-
{formatSeatClass(selectedSchedule)}
- )}
-
- )}
))}
@@ -956,7 +960,7 @@ export default function ReviewPage() {
Total
- {displayCurrency} {(total / 100).toFixed(2)}
+ {formatFare(total, displayCurrencySymbol)}
{createBookingMutation.isError && (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
index 574b08487..e2e75f1d5 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
@@ -17,8 +17,6 @@ import {
Search,
Users,
ChevronDown,
- Gift,
- Check,
X,
ChevronLeft,
Clock,
@@ -54,7 +52,6 @@ const searchSchema = z
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
errorMap: () => ({ message: "Please select your nationality" }),
}),
- promoCode: z.string().optional(),
})
.refine(
(d) => {
@@ -373,8 +370,7 @@ function PassengerModal({
onClick={onClose}
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
>
- Done — {adultCount + childCount} Passenger
- {adultCount + childCount !== 1 ? "s" : ""}
+ Continue
@@ -551,13 +547,6 @@ export default function SearchPage() {
const dark = useDarkMode();
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
- const [promoVisible, setPromoVisible] = useState(false);
- const [promoCode, setPromoCode] = useState("");
- const [promoValidation, setPromoValidation] = useState<{
- valid: boolean;
- message: string;
- } | null>(null);
- const [promoLoading, setPromoLoading] = useState(false);
const [swapping, setSwapping] = useState(false);
const [stationModal, setStationModal] = useState<
"origin" | "destination" | null
@@ -615,7 +604,6 @@ export default function SearchPage() {
// selecting it.
nationality: "" as any,
departureDate: "",
- promoCode: "",
},
});
@@ -693,33 +681,6 @@ export default function SearchPage() {
}, 300);
};
- const handleValidatePromo = async () => {
- if (!promoCode.trim()) return setPromoValidation(null);
- setPromoLoading(true);
- try {
- const res = (await apiClient.post("/promos/validate", {
- code: promoCode,
- })) as any;
- const valid = res.applicable || res.valid;
- setPromoValidation({
- valid,
- message:
- res.message || (valid ? "Promo applied!" : "Invalid promo code"),
- });
- if (valid) setValue("promoCode", promoCode);
- else setPromoCode("");
- } catch (err: any) {
- setPromoValidation({
- valid: false,
- message:
- err?.response?.data?.message || "Promo code is invalid or expired",
- });
- setPromoCode("");
- } finally {
- setPromoLoading(false);
- }
- };
-
const onSubmit = (data: SearchForm) => {
setHasInteracted(true);
// Clear previous booking selections and search cache before starting a new search
@@ -738,7 +699,6 @@ export default function SearchPage() {
nationality: data.nationality,
...(data.tripType === "ROUND_TRIP" &&
data.returnDate && { returnDate: data.returnDate }),
- ...(data.promoCode && { promoCode: data.promoCode }),
});
router.push(`/booking/results?${params}`);
};
@@ -828,16 +788,7 @@ export default function SearchPage() {
)}
{/* ── 90vh hero with banner image ── */}
- {/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
- upward from its bottom-anchored position — give the hero extra height there so the
- widget's top edge doesn't creep up into the sticky header. */}
-
+
{/* Background image with zoom - fully isolated */}
- {totalPassengers} Pax
+ {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
- : " · Select nationality"}
+ : " · Nationality"}
@@ -1204,10 +1155,10 @@ export default function SearchPage() {
>
- {totalPassengers} Pax
+ {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
- : " · Select nationality"}
+ : " · Nationality"}
@@ -1226,340 +1177,129 @@ export default function SearchPage() {
) : (
- // ROUND TRIP: Two row layout
-
- {/* Row 1: From, Swap, To, Departure Date, Return Date */}
-
- {/* From */}
-
-
-
{
- setHasInteracted(true);
- setValue("originStationId", s.id);
- if (s.id) saveRecent(s.id);
- clearErrors("originStationId");
- clearErrors("destinationStationId");
- }}
- error={
- hasInteracted
- ? errors.originStationId?.message
- : undefined
- }
- onOpen={scrollWidgetIntoView}
- />
- {hasInteracted && errors.originStationId && (
-
- {errors.originStationId.message}
-
- )}
-
- {/* Swap */}
+ // ROUND TRIP: Single row — From · Swap · To · Departure · Return · Passengers · Search
+
+ {/* From */}
+
+
+
{
+ setHasInteracted(true);
+ setValue("originStationId", s.id);
+ if (s.id) saveRecent(s.id);
+ clearErrors("originStationId");
+ clearErrors("destinationStationId");
+ }}
+ error={hasInteracted ? errors.originStationId?.message : undefined}
+ onOpen={scrollWidgetIntoView}
+ />
+ {hasInteracted && errors.originStationId && (
+ {errors.originStationId.message}
+ )}
+
+ {/* Swap */}
+
+ {/* To */}
+
+
+
{
+ setHasInteracted(true);
+ setValue("destinationStationId", s.id);
+ if (s.id) saveRecent(s.id);
+ clearErrors("destinationStationId");
+ }}
+ error={hasInteracted ? errors.destinationStationId?.message : undefined}
+ onOpen={scrollWidgetIntoView}
+ />
+ {hasInteracted && errors.destinationStationId && (
+ {errors.destinationStationId.message}
+ )}
+
+ {/* Departure Date */}
+
+
+
{
+ setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
+ trigger("departureDate");
+ trigger("returnDate");
+ }}
+ minDate={new Date()}
+ placeholder="Select date"
+ />
+ {errors.departureDate && (
+ {errors.departureDate.message}
+ )}
+
+ {/* Return Date */}
+
+
+
{
+ setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
+ trigger("returnDate");
+ }}
+ minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
+ placeholder="Select date"
+ />
+ {errors.returnDate && (
+ {errors.returnDate.message}
+ )}
+
+ {/* Passengers */}
+
+
- {/* To */}
-
-
-
{
- setHasInteracted(true);
- setValue("destinationStationId", s.id);
- if (s.id) saveRecent(s.id);
- clearErrors("destinationStationId");
- }}
- error={
- hasInteracted
- ? errors.destinationStationId?.message
- : undefined
- }
- onOpen={scrollWidgetIntoView}
- />
- {hasInteracted && errors.destinationStationId && (
-
- {errors.destinationStationId.message}
-
- )}
-
- {/* Divider */}
-
- {/* Departure Date */}
-
-
-
- {
- setValue(
- "departureDate",
- `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
- );
- trigger("departureDate");
- trigger("returnDate");
- }}
- minDate={new Date()}
- placeholder="Select date"
- />
-
- {errors.departureDate && (
-
- {errors.departureDate.message}
-
- )}
-
- {/* Return Date */}
-
-
-
- {
- setValue(
- "returnDate",
- `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
- );
- trigger("returnDate");
- }}
- minDate={
- departureDate
- ? new Date(departureDate + "T00:00:00")
- : new Date()
- }
- placeholder="Select date"
- />
-
- {errors.returnDate && (
-
- {errors.returnDate.message}
-
- )}
-
-
-
- {/* Row 2: Promo, Passengers, Search */}
-
- {/* Promo Code */}
-
-
- {!promoVisible ? (
-
- ) : (
-
-
-
-
- {
- setPromoCode(
- e.target.value.toUpperCase(),
- );
- if (promoValidation)
- setPromoValidation(null);
- }}
- placeholder="Enter promo code"
- onKeyDown={(e) =>
- e.key === "Enter" &&
- (e.preventDefault(),
- handleValidatePromo())
- }
- className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
- autoFocus
- />
-
-
-
-
- {promoValidation && (
-
- {promoValidation.valid && (
-
- )}
- {promoValidation.message}
-
- )}
-
- )}
-
- {/* Divider */}
-
- {/* Pax + Nationality */}
-
-
-
- {showNationalityError && (
-
{errors.nationality?.message}
- )}
-
- {/* Search Button */}
-
-
-
-
+ {showNationalityError && (
+
{errors.nationality?.message}
+ )}
+ {/* Search */}
+
)}
- {/* Promo - Only visible in ONE WAY mode on desktop */}
- {tripType === "ONE_WAY" && (
-
- {!promoVisible ? (
-
- ) : (
-
-
-
-
- {
- setPromoCode(e.target.value.toUpperCase());
- if (promoValidation) setPromoValidation(null);
- }}
- placeholder="Enter promo code"
- onKeyDown={(e) =>
- e.key === "Enter" &&
- (e.preventDefault(), handleValidatePromo())
- }
- className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
- autoFocus
- />
-
-
-
-
- {promoValidation && (
-
- {promoValidation.valid && (
-
- )}
- {promoValidation.message}
-
- )}
-
- )}
-
- )}
diff --git a/apps/edr-passenger-web/portal/src/lib/useCurrencies.ts b/apps/edr-passenger-web/portal/src/lib/useCurrencies.ts
new file mode 100644
index 000000000..2baa75973
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/lib/useCurrencies.ts
@@ -0,0 +1,28 @@
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from './api-client';
+
+interface Currency {
+ code: string;
+ symbol: string;
+ name: string;
+}
+
+const FALLBACK_SYMBOLS: Record = {
+ ETB: 'Br',
+ DJF: 'Fdj',
+ USD: '$',
+};
+
+export function useCurrencies() {
+ return useQuery({
+ queryKey: ['currencies'],
+ queryFn: () => apiClient.get('/currencies'),
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCurrencySymbol(code: string): string {
+ const { data, isLoading, isError } = useCurrencies();
+ if (isLoading || isError || !data) return FALLBACK_SYMBOLS[code] ?? code;
+ return data.find(c => c.code === code)?.symbol ?? FALLBACK_SYMBOLS[code] ?? code;
+}
diff --git a/apps/edr-passenger-web/portal/src/utils/fare-utils.ts b/apps/edr-passenger-web/portal/src/utils/fare-utils.ts
index 22fdaff31..3206c0a49 100644
--- a/apps/edr-passenger-web/portal/src/utils/fare-utils.ts
+++ b/apps/edr-passenger-web/portal/src/utils/fare-utils.ts
@@ -83,8 +83,8 @@ export function getPassengerCategory(passenger: PassengerWithAge): 'ADULT' | 'CH
/**
* Format fare amount for display
*/
-export function formatFare(amountMinor: number, currency: string = 'ETB'): string {
- return `${currency} ${(amountMinor / 100).toFixed(2)}`;
+export function formatFare(amountMinor: number, currencyOrSymbol: string = 'ETB'): string {
+ return `${currencyOrSymbol} ${(amountMinor / 100).toFixed(2)}`;
}
/**