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 7cbc6d881..9f9fb7bdc 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -398,10 +398,24 @@ export class SearchService { this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), ]); - // Compute per-class availability using the pre-computed free seat set + // Compute per-class availability using the pre-computed free seat set. A coach type + // has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND + // "VIP Bed Upper (Intl)" on the same coach) — filter to the searching passenger's own + // nationality first, otherwise a name-based `.find()` across both tiers would credit + // all availability to whichever tier happens to come first in the query result, + // leaving the other tier's class permanently at 0 ("Fully booked") even when seats + // are actually free. Matched via the class's own bedPosition field (case-insensitive: + // Seat.bedPosition is lowercase, SeatClass.bedPosition is uppercase) rather than a + // name substring, since that's an exact, unambiguous signal. + const nationalityUpper = (nationality ?? '').toUpperCase(); + const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { - const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; + const seatClasses = (assignment.coach.coachType?.seatClasses ?? []).filter( + (sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType, + ); const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); if (isBedCoach) { @@ -412,8 +426,8 @@ export class SearchService { if (freeSeats.has(seat.id)) count++; } if (count > 0) { - const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); - if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count; + const matchingClass = seatClasses.find((sc: any) => sc.bedPosition?.toLowerCase() === bedPosition); + if (matchingClass) availabilityByClass[matchingClass.name] = (availabilityByClass[matchingClass.name] ?? 0) + count; } } } else { @@ -422,11 +436,12 @@ export class SearchService { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; if (freeSeats.has(seat.id)) available++; } - for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; + const names = seatClasses.length > 0 ? seatClasses.map((sc: any) => sc.name) : ['Standard']; + for (const name of names) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality, availabilityByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -748,12 +763,13 @@ export class SearchService { schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, nationality?: string, + availabilityByClass: Record = {}, ): Array<{ coachTypeId: string; coachTypeName: string; coachTypeCode: string; coachId: string; - classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>; + classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number }>; }> { const coachTypeMap = new Map< string, @@ -795,9 +811,10 @@ export class SearchService { baseFareMinor: fareInfo.baseFareMinor, displayCurrency: fareInfo.displayCurrency, displayAmountMinor: fareInfo.displayAmountMinor, + available: availabilityByClass[className] ?? 0, }; }) - .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) + .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); if (classes.length === 0) continue; 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 e13d5dab6..45331e761 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 @@ -25,6 +25,15 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format"; import { formatFare } from "@/utils/fare-utils"; import { useState, useEffect } from "react"; +// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach" +// modal, so both pick the same icon for a given coach type name. +const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes("soft") || lower.includes("vip")) return Star; + if (lower.includes("bed")) return Bed; + return Armchair; +}; + export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); @@ -363,13 +372,6 @@ export default function ResultsPage() { (ct: any) => ct.coachTypeCode !== "DPC", ); - const getCoachIcon = (typeName: string) => { - const lower = typeName.toLowerCase(); - if (lower.includes("soft") || lower.includes("vip")) return Star; - if (lower.includes("bed")) return Bed; - return Armchair; - }; - return ( <>
Class Options

- - {coachType.classes.length} available - + {coachType.classes.some((c: any) => c.available != null) && ( + + {coachType.classes.reduce((sum: number, c: any) => sum + (c.available ?? 0), 0)} seats left + + )}
{coachType.classes.map( @@ -530,9 +534,26 @@ export default function ResultsPage() { >
- - {cls.name} - +
+ + {cls.name} + + {cls.available != null && ( + + {cls.available === 0 + ? "Fully booked" + : `${cls.available} seat${cls.available === 1 ? "" : "s"} left`} + + )} +
@@ -774,6 +795,48 @@ export default function ResultsPage() {
+ + {schedule.coachTypes && schedule.coachTypes.length > 0 && ( +
+ {schedule.coachTypes + .filter((ct: any) => ct.coachTypeCode !== "DPC") + .map((ct: any, idx: number) => { + const CoachIcon = getCoachIcon(ct.coachTypeName); + // Only classes that actually reported a count contribute — if none of + // them did (API didn't return `available` for this coach type), there's + // nothing honest to show, so the count is omitted rather than shown as 0. + const hasAvailabilityData = ct.classes.some( + (c: any) => c.available != null, + ); + const available = ct.classes.reduce( + (sum: number, c: any) => sum + (c.available ?? 0), + 0, + ); + return ( + + + {ct.coachTypeName} + {hasAvailabilityData && ( + + {available === 0 ? "Full" : `${available} left`} + + )} + + ); + })} +
+ )} ); }; diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index d1a906cbb..c299f10fd 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -51,6 +51,7 @@ export interface Schedule { baseFareMinor: number; displayCurrency?: string; displayAmountMinor?: number; + available?: number; }>; }>; displayCurrency?: string;