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)

+
+
+ {errors.passengers?.[index]?.passportIssueDate && ( +

{errors.passengers[index]?.passportIssueDate?.message}

+ )}
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 fb44dce41..afaeb1f44 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 251f8ae8b..e4ab0a913 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 02c1fcd35..59ae5f3f8 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 @@ -621,7 +610,6 @@ export default function SearchPage() { // selecting it. nationality: "" as any, departureDate: "", - promoCode: "", }, }); @@ -699,33 +687,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 @@ -744,7 +705,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}`); }; @@ -833,21 +793,10 @@ export default function SearchPage() { /> )} - {/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */} - {/* Round trip stacks an extra Return Date field into the widget on desktop, 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. On mobile the widget is - in normal flow (not bottom-anchored), so this only applies at md: and up. */} -
- {/* Background image with zoom — desktop only; mobile drops the hero image entirely - so the booking widget can sit at the top and use the available space. */} -
+ {/* ── 90vh hero with banner image ── */} +
+ {/* Background image with zoom - fully isolated */} +
- {totalPassengers} Pax + {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"} {nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` - : " · Select nationality"} + : " · Nationality"} @@ -1244,10 +1193,10 @@ export default function SearchPage() { > - {totalPassengers} Pax + {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"} {nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` - : " · Select nationality"} + : " · Nationality"} @@ -1266,342 +1215,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" - error={!!errors.departureDate} - /> -
- {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" - error={!!errors.returnDate} - /> -
- {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)}`; } /**