From c50abbffaac2e08b9c169d4b3905b672ad567326 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 27 Jun 2026 07:16:08 +0300 Subject: [PATCH] Update fare display based on currency and fix seat allocation --- .../modules/fare-engine/fare-engine.dto.ts | 10 +- .../notifications/notifications.service.ts | 24 +- .../modules/payments/payments.controller.ts | 10 +- .../src/modules/payments/payments.service.ts | 3 +- .../src/modules/search/search.service.ts | 52 +- .../src/modules/seats/seats.service.ts | 77 ++- .../src/modules/tickets/tickets.service.ts | 36 +- .../src/app/booking/auth-check/page.tsx | 193 +++--- .../src/app/booking/passengers/page.tsx | 5 +- .../portal/src/app/booking/payment/page.tsx | 631 +++++++----------- .../booking/payment/telebirr/failure/page.tsx | 5 +- .../booking/payment/waafi/failure/page.tsx | 5 +- .../portal/src/app/booking/results/page.tsx | 38 +- .../portal/src/app/booking/review/page.tsx | 231 ++++--- .../portal/src/app/booking/seats/page.tsx | 27 +- .../portal/src/lib/booking-store.ts | 1 + .../portal/src/types/index.ts | 7 +- 17 files changed, 662 insertions(+), 693 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 5d3a288cd..5652ae91d 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { Currency } from '@prisma/client'; -// Nationality → home currency mapping +// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup) export const NATIONALITY_CURRENCY_MAP: Record = { - Ethiopian: Currency.ETB, - Djiboutian: Currency.DJF, + ETHIOPIAN: Currency.ETB, + DJIBOUTIAN: Currency.DJF, }; export function resolveCurrencyFromNationality(nationality?: string): Currency { if (!nationality) return Currency.ETB; - return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD; + return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD; } export class FareCalculateDto { @@ -29,7 +29,7 @@ export class FareCalculateDto { @ApiPropertyOptional({ example: 'Ethiopian', - description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.', + description: 'Passenger nationality. Determines the billing currency: ETHIOPIAN → ETB, DJIBOUTIAN → DJF, other → USD. Case-insensitive. Defaults to ETB.', }) @IsOptional() @IsString() nationality?: string; diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index c7ed031cd..2660fe616 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -295,20 +295,34 @@ export class NotificationsService { { category: 'PAYMENT', deepLink: `edr://tickets/${ref}` }, ); + // Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone + // the passenger entered on the booking form (contactPhone). + const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null; + const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null; + const smsPhone = iamPhone ?? contactPhone; + // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation. if (!ticket || !booking) { this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`); const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`; await this.deliverEmail(passengerId, `Payment received — ${ref}`, text); - await this.deliverSms(passengerId, text); + if (smsPhone) { + await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null); + } else { + this.logger.warn(`No SMS phone for booking ${ref}`); + } return; } // SMS — short pointer (no HTML/QR over SMS). - await this.deliverSms( - passengerId, - `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, - ); + if (smsPhone) { + await this.smsClient.sendSms({ + to: smsPhone, + message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, + }).catch(() => null); + } else { + this.logger.warn(`No SMS phone for booking ${ref}`); + } // EMAIL — rich HTML ticket with plain-text fallback. await this.deliverEmail( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 6a1df8cb1..aea08150e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -128,12 +128,16 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.", + "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", }) + @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods(@Query("region") region?: PaymentRegionEnum) { - return this.service.getSupportedPaymentMethods(region); + getMethods( + @Query("currency") currency?: string, + @Query("region") region?: PaymentRegionEnum, + ) { + return this.service.getSupportedPaymentMethods(region, currency); } @Get("checkout") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7453e1c06..98d7520cf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -473,7 +473,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum) { + getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -487,6 +487,7 @@ export class PaymentsService { }, } : {}), + ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); 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 4797c6c19..3a7b02682 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; +import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; @@ -307,9 +308,13 @@ export class SearchService { (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, ); - const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); - const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); - const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); + const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); + const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0); + const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB; results.push({ type: 'TRANSIT', @@ -318,7 +323,9 @@ export class SearchService { connectionMinutes, leg1: leg1Result, leg2: leg2Result, + displayCurrency, combinedMinFareMinor, + combinedMinFareDisplay, // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, @@ -379,6 +386,8 @@ export class SearchService { const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; + const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality); + return { type: 'DIRECT', scheduleId: schedule.id, @@ -395,6 +404,7 @@ export class SearchService { .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), + displayCurrency, faresByClass, coachTypes, }; @@ -467,7 +477,7 @@ export class SearchService { const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); - const displayCurrency = dto.displayCurrency ?? Currency.ETB; + const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; @@ -494,7 +504,9 @@ export class SearchService { originStationId: string, destinationStationId: string, nationality?: string, - ): Promise> { + ): Promise> { + const displayCurrency = resolveCurrencyFromNationality(nationality); + const seatClassIds: string[] = Array.from( new Set( schedule.coachAssignments @@ -535,8 +547,10 @@ export class SearchService { scheduleId: schedule.id, }); return { - seatClassName: fare.seatClassName, - baseFareMinor: fare.baseFarePerPassengerMinor, + seatClassName: fare.seatClassName, + baseFareMinor: fare.baseFarePerPassengerMinor, + displayCurrency: fare.billingCurrency as Currency, + displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; } catch (error) { console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); @@ -545,7 +559,9 @@ export class SearchService { }), ); - const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null); + const validResults = results.filter( + (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, + ); if (validResults.length > 0) { return validResults; } @@ -573,9 +589,12 @@ export class SearchService { if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); + const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', - baseFareMinor: rule.baseFareMinor, + seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + baseFareMinor: rule.baseFareMinor, + displayCurrency, + displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), })); } } @@ -586,13 +605,13 @@ export class SearchService { private async buildCoachTypeDetails( schedule: any, - faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, + faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, ): Promise; + classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>; }>> { const coachTypeMap = new Map< string, @@ -621,9 +640,14 @@ export class SearchService { .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); if (!fareInfo) return null; - return { name: className, baseFareMinor: fareInfo.baseFareMinor }; + return { + name: className, + baseFareMinor: fareInfo.baseFareMinor, + displayCurrency: fareInfo.displayCurrency, + displayAmountMinor: fareInfo.displayAmountMinor, + }; }) - .filter((c): c is { name: string; baseFareMinor: number } => c !== null) + .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index f67e9b3f3..4a0931c6e 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -307,9 +307,9 @@ export class SeatsService { throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); } - const blocked = seats.filter(s => s.status === 'BLOCKED'); + const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD'); if (blocked.length > 0) - throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`); + throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber])); @@ -334,28 +334,34 @@ export class SeatsService { select: { seatIds: true, createdBy: true }, }); - const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = []; + const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = []; for (const h of activeHolds) { + const rawSeatIds = h.seatIds as string[]; try { if (h.createdBy?.trimStart().startsWith('{')) { const meta = JSON.parse(h.createdBy); const holdFrom = seqOf(meta.originStationId); const holdTo = seqOf(meta.destinationStationId); - if (holdFrom !== undefined && holdTo !== undefined) { - parsedHolds.push({ - seatIds: h.seatIds, - from: holdFrom, - to: holdTo, - passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), - }); - } + parsedHolds.push({ + seatIds: rawSeatIds, + from: holdFrom ?? 0, + to: holdTo ?? Number.MAX_SAFE_INTEGER, + passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), + legUnknown: holdFrom === undefined || holdTo === undefined, + }); + } else { + // Legacy plain-string createdBy — can't determine leg; block conservatively. + parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true }); } - } catch { /* ignore */ } + } catch { + // Malformed JSON — block conservatively. + parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true }); + } } for (const { passengerId, seatId } of dto.passengers) { for (const hold of parsedHolds) { - const legsOverlap = hold.from < reqTo && reqFrom < hold.to; + const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to); if (!legsOverlap) continue; if (hold.seatIds.includes(seatId)) { @@ -364,7 +370,7 @@ export class SeatsService { ); } - if (hold.passengerIds.includes(passengerId)) { + if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) { throw new ConflictException( `Passenger already holds a seat on this journey leg`, ); @@ -386,12 +392,14 @@ export class SeatsService { if (!seg.seatId) continue; const segFrom = seqOf(seg.departureStationId); const segTo = seqOf(seg.arrivalStationId); - if (segFrom !== undefined && segTo !== undefined) { - if (segFrom < reqTo && reqFrom < segTo) { - throw new ConflictException( - `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, - ); - } + // If stations can't be resolved, assume overlap (conservative) to prevent double-booking. + const overlaps = (segFrom === undefined || segTo === undefined) + ? true + : segFrom < reqTo && reqFrom < segTo; + if (overlaps) { + throw new ConflictException( + `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, + ); } } @@ -401,6 +409,13 @@ export class SeatsService { passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), }; + // Mark seats as HELD so the status check catches them immediately on any + // subsequent hold attempt (avoids relying solely on the SeatHold table scan). + await tx.seat.updateMany({ + where: { id: { in: seatIds } }, + data: { status: 'HELD' }, + }); + return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, @@ -541,11 +556,16 @@ export class SeatsService { async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.seatHold.delete({ where: { id: holdId } }); + await this.prisma.$transaction([ + this.prisma.seat.updateMany({ + where: { id: { in: hold.seatIds as string[] }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }), + this.prisma.seatHold.delete({ where: { id: holdId } }), + ]); return { released: true, holdId }; } - // Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy. async confirmSeats(_seatIds: string[]) {} // Delete the Journey (and its JourneySegments) scoped to this booking. @@ -719,7 +739,18 @@ export class SeatsService { @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - // Holds are temporary and don't create Journey rows — just delete expired ones. + const expired = await this.prisma.seatHold.findMany({ + where: { expiresAt: { lt: new Date() } }, + select: { id: true, seatIds: true }, + }); + if (expired.length === 0) return; + + const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); + // Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched. + await this.prisma.seat.updateMany({ + where: { id: { in: expiredSeatIds }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }); await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index ecc17dc53..f39c268e2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -15,6 +15,8 @@ interface OfflineValidation { @Injectable() export class TicketsService { + private readonly logger = new Logger(TicketsService.name); + constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, @@ -154,17 +156,29 @@ export class TicketsService { ); } - // Booking not in CONFIRMED state (safety net — should align with SUCCEEDED) + // Booking not in CONFIRMED state — could be a webhook delivery failure. + // If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT, + // self-heal here rather than rejecting a legitimately paid booking. if (booking.status !== 'CONFIRMED') { - throw new HttpException( - { - status: 'error', - message: 'Payment not completed', - code: 400, - detail: `Booking status: ${booking.status}`, - }, - HttpStatus.BAD_REQUEST, - ); + if (booking.status === 'PENDING_PAYMENT') { + this.logger.warn( + `Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`, + ); + await this.prisma.booking.update({ + where: { id: bookingId }, + data: { status: 'CONFIRMED' }, + }); + } else { + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Booking status: ${booking.status}`, + }, + HttpStatus.BAD_REQUEST, + ); + } } // Build a compact multi-leg payload for the QR so gate scanners see all legs diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index c1787010c..0ef5c74ec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -1,9 +1,42 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { LogIn, UserPlus, Shield, Clock } from 'lucide-react'; +import { LogIn, UserPlus, ChevronLeft } from 'lucide-react'; + +function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { + const [visible, setVisible] = useState(false); + + return ( +
setVisible(true)} + onMouseLeave={() => setVisible(false)} + onFocus={() => setVisible(true)} + onBlur={() => setVisible(false)} + > + {children} +
+
    + {content.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ {/* Arrow */} +
+
+
+ ); +} export default function AuthCheckPage() { const router = useRouter(); @@ -19,122 +52,54 @@ export default function AuthCheckPage() { } }, [isAuthenticated, router]); - const handleSignIn = () => { - router.push('/login?redirect=/booking/passengers'); - }; - - const handleGuest = () => { - router.push('/booking/passengers'); - }; - return ( -
-
-
- {/* Header */} -
-

Continue your booking

-

- Sign in to access saved profiles or continue as a guest -

-
+
+
+

+ Continue your booking +

+

+ Choose how you'd like to proceed +

- {/* Options Grid */} -
- {/* Sign In Option */} -
+ + -
-
- - {/* Guest Option */} -
-
-
- -
-

Continue as guest

-

- Book without an account. You can create one after completing your booking -

- - {/* Benefits */} -
-
-
- -
- Quick checkout process -
-
-
- -
- No account required -
-
-
- -
- Create account later (optional) -
-
- - -
-
-
- - {/* Back Link */} -
- -
+ + + + + +
+ +
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 272992b73..92b82d2cb 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useState, useEffect, useRef } from 'react'; -import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react'; +import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react'; import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar'; const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; @@ -951,7 +951,8 @@ export default function PassengersPage() { ...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }), }); router.push(`/booking/results?${params}`); - }} className="btn-secondary flex-1" disabled={saving}> + }} className="btn-secondary flex-1 flex items-center justify-center gap-2" disabled={saving}> + Back + +

+ 🔒 Secure & encrypted payment +

+
+
+ ); + return ( -
+
-

- Complete payment -

-

- Booking reference:{" "} - {pnr} -

+

Complete payment

{/* Payment Processing Overlay */} {isProcessing && ( -
-
+
+
{paymentMutation.isSuccess ? ( <> - -

- Payment successful! -

-

- Redirecting to confirmation... -

- + +

Payment successful!

+

Redirecting to confirmation...

+ ) : ( <> - -

- Processing payment -

-

- Please wait while we process your payment... -

+ +

Processing payment

+

Please wait...

)}
)} - {/* Order Summary */} -
-

- Order summary -

-
- {isRoundTrip ? ( - <> - {/* Outbound Journey */} -
-
-
- Outbound Journey - - {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {outboundSchedule?.origin} -
-
+ {/* Two-column grid */} +
- {/* Journey Info */} -
-
-
- - - - {outboundSchedule?.duration} -
-
- - - - Train {outboundSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {outboundSchedule?.destination} -
-
-
-
- -
-
- Outbound fare - ETB {(outboundBaseFare / 100).toFixed(2)} -
-
+ {/* Left column — payment methods (2/3 width) */} +
+
+

Select payment method

+ {loadingMethods ? ( +
+ + Loading payment methods...
- - {/* Return Journey */} -
-
-
- Return Journey - - {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {inboundSchedule?.origin} -
-
- - {/* Journey Info */} -
-
-
- - - - {inboundSchedule?.duration} -
-
- - - - Train {inboundSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {inboundSchedule?.destination} -
-
-
-
- -
-
- Return fare - ETB {(inboundBaseFare / 100).toFixed(2)} -
-
+ ) : error ? ( +
+

Failed to load payment methods. Please refresh.

- - ) : ( - <> - {/* One-Way Journey */} -
-
-
- Your Journey - - {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {selectedSchedule?.origin} -
-
- - {/* Journey Info */} -
-
-
- - - - {selectedSchedule?.duration} -
-
- - - - Train {selectedSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {selectedSchedule?.destination} -
-
-
-
+ ) : paymentMethods.length === 0 ? ( +
+

No payment methods available at the moment.

- - )} - - {/* Passengers and Total */} -
-
- - Passengers - - - {passengers.length} passenger{passengers.length !== 1 ? "s" : ""} - -
-
- - Total amount - - - ETB {(totalAmount / 100).toFixed(2)} - -
-
-
-
- - {/* Payment Methods */} -
-

- Select payment method -

- {loadingMethods ? ( -
- -

Loading payment methods...

-
- ) : error ? ( -
-

- Failed to load payment methods. Please refresh the page. -

-
- ) : paymentMethods.length === 0 ? ( -
-

- No payment methods available at the moment. -

-
- ) : ( -
- {paymentMethods.map((method) => { - const Icon = getIconForMethod(method.type); - const isSelected = selectedMethod === method.type; - return ( -
-
-

- {method.displayName} -

-

- {method.region} · {method.currency} -

-
- {isSelected && ( -
- +
+
+ +
+
+

{method.displayName}

+

{method.region} · {method.currency}

+
+ {isSelected && ( + + )}
- )} -
- - ); - })} + + ); + })} +
+ )}
- )} -
- {/* Action Buttons */} -
- - - -
- - {/* Error Message */} - {paymentError && ( -
-

- ⚠️ {paymentError} -

+ {/* Order summary inline — mobile only */} +
+ +
- )} - {/* Security Notice */} -
-

- 🔒 Your payment is secure and encrypted. We do not store your - payment information. -

-
+ {/* Right column — sticky order summary (desktop only) */} +
+
+ +
+
+ +
{/* end grid */}
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + {displayCurrency} {(totalAmount / 100).toFixed(2)} +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} +
+ + +
+
+
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx index 53da93781..04c2d8251 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; import { useEffect, Suspense } from 'react'; -import { XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; function TelebirrFailureContent() { const router = useRouter(); @@ -36,7 +36,8 @@ function TelebirrFailureContent() { Try Again
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx index 19e3832fe..1e49d5889 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; import { useEffect, Suspense } from 'react'; -import { XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; function WaafiFailureContent() { const router = useRouter(); @@ -39,7 +39,8 @@ function WaafiFailureContent() { Try Again
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 38693ede6..10eab03e0 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 @@ -159,12 +159,17 @@ export default function ResultsPage() { // Find the coach type to get pricing info const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); - const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; - + // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. + const minFare = coachType?.classes.length + ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) + : 0; + const fareCurrency: string = + coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB'; + const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; - + const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, @@ -175,6 +180,7 @@ export default function ResultsPage() { duration: durationStr, baseFareAdult: minFare, baseFareChild: minFare, + displayCurrency: fareCurrency, selectedSeatClass: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name, selectedCoachTypeId: selectedCoachType.id, @@ -213,13 +219,22 @@ export default function ResultsPage() { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; - // Calculate lowest fare from coach types + // Calculate lowest fare and display currency from coach types / faresByClass. + // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; + let displayCurrency = schedule.displayCurrency || 'ETB'; if (schedule.coachTypes?.length) { - const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0); + const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); + const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; + const firstWithCurrency = allClasses.find(c => c.displayCurrency); + if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; } else if (schedule.faresByClass?.length) { - lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)); + lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); + const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency); + if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; + } else if (schedule.combinedMinFareDisplay) { + lowestFare = schedule.combinedMinFareDisplay; } const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -287,7 +302,7 @@ export default function ResultsPage() {
Starting from
- {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} + {lowestFare ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
{selectedCoachType && ( @@ -536,7 +551,8 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => { const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; + const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; const CoachIcon = getCoachIcon(coachType.coachTypeName); return ( @@ -585,7 +601,7 @@ export default function ResultsPage() { }`}> {(minPrice / 100).toFixed(2)} - ETB + {coachCurrency}
@@ -615,10 +631,10 @@ export default function ResultsPage() {
- {(cls.baseFareMinor / 100).toFixed(2)} + {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} - ETB + {cls.displayCurrency ?? coachCurrency}
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 7b94503a0..c74574d65 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 @@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; // Helper function to decode JWT token and extract passengerId function getPassengerIdFromToken(token: string): string | null { @@ -58,6 +59,14 @@ 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 NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; + const displayCurrency: string = + (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ?? + NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? + 'USD'; + useEffect(() => { if (!seatHold?.expiresAt) return; @@ -79,55 +88,54 @@ export default function ReviewPage() { return () => clearInterval(interval); }, [seatHold]); + const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ''; + if (!base) return 'N/A'; + const posMap: Record = { lower: 'L', middle: 'M', upper: 'U' }; + const suffix = seat.bedPosition ? (posMap[seat.bedPosition] ?? '') : ''; + return suffix ? `${base}${suffix}` : base; + }; + useEffect(() => { const fetchSeatDetails = async () => { try { const details: Record = {}; - + // Fetch outbound seat details if (isRoundTrip && outboundSchedule?.id) { const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); - const outboundCoaches = outboundSeatMap?.coaches || []; - const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []); - + const outboundSeats = (outboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if ((p as any).outboundSeatId) { const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); - if (seat) { - details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[`outbound-${(p as any).outboundSeatId}`] = buildSeatLabel(seat); } }); } - + // Fetch inbound seat details if (isRoundTrip && inboundSchedule?.id) { const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); - const inboundCoaches = inboundSeatMap?.coaches || []; - const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []); - + const inboundSeats = (inboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if ((p as any).inboundSeatId) { const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); - if (seat) { - details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[`inbound-${(p as any).inboundSeatId}`] = buildSeatLabel(seat); } }); } - + // Fetch one-way seat details if (!isRoundTrip && selectedSchedule?.id) { const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); - const coaches = seatMapData?.coaches || []; - const allSeats = coaches.flatMap((coach: any) => coach.seats || []); - + const allSeats = (seatMapData?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if (p.seatId) { const seat = allSeats.find((s: any) => s.id === p.seatId); - if (seat) { - details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[p.seatId] = buildSeatLabel(seat); } }); } @@ -249,7 +257,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: 'ETB', + displayCurrency: displayCurrency, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { @@ -288,7 +296,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: 'ETB', + displayCurrency: displayCurrency, passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { @@ -373,21 +381,88 @@ export default function ReviewPage() { }, 0); const total = baseFare; + // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) + const FareSidebar = () => ( +
+

+ Fare breakdown +

+ {passengers.map((p, i) => { + const outFare = outboundSchedule?.baseFareAdult || 0; + const inFare = inboundSchedule?.baseFareAdult || 0; + const onewayFare = selectedSchedule?.baseFareAdult || 0; + const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; + return ( +
+
+ + {p.name || `Passenger ${i + 1}`} + + + {displayCurrency} {(passengerTotal / 100).toFixed(2)} + +
+ {isRoundTrip && ( +
+
+ Outbound + {displayCurrency} {(outFare / 100).toFixed(2)} +
+
+ Return + {displayCurrency} {(inFare / 100).toFixed(2)} +
+
+ )} +
+ ); + })} +
+ Total + {displayCurrency} {(total / 100).toFixed(2)} +
+ + {/* Action buttons — visible only in desktop sidebar */} +
+ {createBookingMutation.isError && ( +

+ ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'} +

+ )} + + +
+
+ ); + return ( -
+
-

Review your booking

+

Review your booking

{seatHold && ( -
-

- ⏱️ Your seats will be released in: {timeLeft} -

+
+ + ⏱️ Seats held for: {timeLeft} +
)} -
+ {/* Two-column layout on desktop */} +
+ + {/* Left column — trip details + passengers */} +
{/* Outbound Trip Details */} {isRoundTrip && outboundSchedule && (
@@ -646,67 +721,47 @@ export default function ReviewPage() {
-
-

Fare breakdown

-
- {passengers.map((p, i) => { - const outFare = outboundSchedule?.baseFareAdult || 0; - const inFare = inboundSchedule?.baseFareAdult || 0; - const onewayFare = selectedSchedule?.baseFareAdult || 0; - const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; - return ( -
-
- - {p.name || `Passenger ${i + 1}`} - - - ETB {(passengerTotal / 100).toFixed(2)} - -
- {isRoundTrip && ( -
-
- Outbound - ETB {(outFare / 100).toFixed(2)} -
-
- Return - ETB {(inFare / 100).toFixed(2)} -
-
- )} -
- ); - })} -
- Total - ETB {(total / 100).toFixed(2)} -
+ {/* Fare breakdown — visible only on mobile (desktop shows it in right column) */} +
+ +
+ +
{/* end left column */} + + {/* Right column — sticky fare card (desktop only) */} +
+
+
-
- - -
- - {createBookingMutation.isError && ( -
-

- ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'} -

-
- )} -
+
{/* end grid */} +
+
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + {displayCurrency} {(total / 100).toFixed(2)} +
+ {createBookingMutation.isError && ( +

+ ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'} +

+ )} +
+ +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index ba3fe0159..696dcb45b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -12,6 +12,15 @@ import Image from "next/image"; import CustomModal from "@/components/CustomModal"; +const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; + +const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ''; + if (!base) return ''; + const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : ''; + return suffix ? `${base}${suffix}` : base; +}; + const BedCard = memo(({ bed, isSelected, onToggle }: any) => { const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; const bedPosition = bed.bedPosition || ""; @@ -364,11 +373,7 @@ export default function SeatsPage() { return { ...p, outboundSeatId: selectedSeats[i], - outboundSeatNumber: - seatData?.number || - seatData?.label || - seatData?.seatNumber || - "", + outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); @@ -401,18 +406,13 @@ export default function SeatsPage() { return { ...p, inboundSeatId: selectedSeats[i], - inboundSeatNumber: - seatData?.number || - seatData?.label || - seatData?.seatNumber || - "", + inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', }; } return { ...p, seatId: selectedSeats[i], - seatNumber: - seatData?.number || seatData?.label || seatData?.seatNumber || "", + seatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); @@ -456,8 +456,7 @@ export default function SeatsPage() { return { ...p, seatId: autoSelectedSeats[i], - seatNumber: - seatData?.number || seatData?.label || seatData?.seatNumber || "", + seatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 012fbef44..864dd8086 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -44,6 +44,7 @@ export interface SelectedSchedule { duration: string; baseFareAdult: number; baseFareChild: number; + displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; } diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index 08203494a..1baabfd3b 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -38,7 +38,7 @@ export interface Schedule { baseFareChild?: number; availableSeats?: number; availabilityByClass?: Record; // API returns this - faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this + faresByClass?: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency?: string; displayAmountMinor?: number }>; // API returns this coachTypes?: Array<{ coachId: string; coachTypeName: string; @@ -46,8 +46,13 @@ export interface Schedule { classes: Array<{ name: string; baseFareMinor: number; + displayCurrency?: string; + displayAmountMinor?: number; }>; }>; + displayCurrency?: string; + combinedMinFareMinor?: number; + combinedMinFareDisplay?: number; serviceClass?: string; status?: string; hasAvailability?: boolean;