Show seat left on booking result

This commit is contained in:
Roba Boru
2026-07-14 22:51:26 +03:00
parent 93133ed0a1
commit 7dc130d0ad
3 changed files with 102 additions and 21 deletions

View File

@@ -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<string, number> = {};
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<string, number> = {},
): 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;

View File

@@ -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 (
<>
<div
@@ -517,9 +519,11 @@ export default function ResultsPage() {
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Class Options
</p>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachType.classes.length} available
</span>
{coachType.classes.some((c: any) => c.available != null) && (
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachType.classes.reduce((sum: number, c: any) => sum + (c.available ?? 0), 0)} seats left
</span>
)}
</div>
<div className="space-y-2.5">
{coachType.classes.map(
@@ -530,9 +534,26 @@ export default function ResultsPage() {
>
<div className="flex items-center gap-2.5">
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{cls.name}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{cls.name}
</span>
{cls.available != null && (
<span
className={`text-[11px] font-medium ${
cls.available === 0
? "text-red-500"
: cls.available <= 3
? "text-amber-600 dark:text-amber-400"
: "text-gray-400 dark:text-gray-500"
}`}
>
{cls.available === 0
? "Fully booked"
: `${cls.available} seat${cls.available === 1 ? "" : "s"} left`}
</span>
)}
</div>
</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
@@ -774,6 +795,48 @@ export default function ResultsPage() {
</div>
</div>
</div>
{schedule.coachTypes && schedule.coachTypes.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800 flex flex-wrap gap-2">
{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 (
<span
key={idx}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${
!hasAvailabilityData
? "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
: available === 0
? "bg-red-50 dark:bg-red-900/20 text-red-500 dark:text-red-400"
: available <= 3
? "bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
<CoachIcon className="w-3.5 h-3.5 flex-shrink-0" />
{ct.coachTypeName}
{hasAvailabilityData && (
<span className="font-semibold">
{available === 0 ? "Full" : `${available} left`}
</span>
)}
</span>
);
})}
</div>
)}
</div>
);
};

View File

@@ -51,6 +51,7 @@ export interface Schedule {
baseFareMinor: number;
displayCurrency?: string;
displayAmountMinor?: number;
available?: number;
}>;
}>;
displayCurrency?: string;