mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
Show seat left on booking result
This commit is contained in:
@@ -398,10 +398,24 @@ export class SearchService {
|
|||||||
this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality),
|
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> = {};
|
const availabilityByClass: Record<string, number> = {};
|
||||||
for (const assignment of schedule.coachAssignments) {
|
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);
|
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
|
||||||
|
|
||||||
if (isBedCoach) {
|
if (isBedCoach) {
|
||||||
@@ -412,8 +426,8 @@ export class SearchService {
|
|||||||
if (freeSeats.has(seat.id)) count++;
|
if (freeSeats.has(seat.id)) count++;
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
const matchingClass = seatClasses.find((sc: any) => sc.bedPosition?.toLowerCase() === bedPosition);
|
||||||
if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
|
if (matchingClass) availabilityByClass[matchingClass.name] = (availabilityByClass[matchingClass.name] ?? 0) + count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -422,11 +436,12 @@ export class SearchService {
|
|||||||
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||||
if (freeSeats.has(seat.id)) available++;
|
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 legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
@@ -748,12 +763,13 @@ export class SearchService {
|
|||||||
schedule: ScheduleWithIncludes,
|
schedule: ScheduleWithIncludes,
|
||||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
|
availabilityByClass: Record<string, number> = {},
|
||||||
): Array<{
|
): Array<{
|
||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachTypeName: string;
|
coachTypeName: string;
|
||||||
coachTypeCode: string;
|
coachTypeCode: string;
|
||||||
coachId: 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<
|
const coachTypeMap = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -795,9 +811,10 @@ export class SearchService {
|
|||||||
baseFareMinor: fareInfo.baseFareMinor,
|
baseFareMinor: fareInfo.baseFareMinor,
|
||||||
displayCurrency: fareInfo.displayCurrency,
|
displayCurrency: fareInfo.displayCurrency,
|
||||||
displayAmountMinor: fareInfo.displayAmountMinor,
|
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);
|
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
if (classes.length === 0) continue;
|
if (classes.length === 0) continue;
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
|||||||
import { formatFare } from "@/utils/fare-utils";
|
import { formatFare } from "@/utils/fare-utils";
|
||||||
import { useState, useEffect } from "react";
|
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() {
|
export default function ResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -363,13 +372,6 @@ export default function ResultsPage() {
|
|||||||
(ct: any) => ct.coachTypeCode !== "DPC",
|
(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<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">
|
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
Class Options
|
Class Options
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
{coachType.classes.some((c: any) => c.available != null) && (
|
||||||
{coachType.classes.length} available
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
</span>
|
{coachType.classes.reduce((sum: number, c: any) => sum + (c.available ?? 0), 0)} seats left
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{coachType.classes.map(
|
{coachType.classes.map(
|
||||||
@@ -530,9 +534,26 @@ export default function ResultsPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
<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">
|
<div className="flex flex-col">
|
||||||
{cls.name}
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
</span>
|
{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>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface Schedule {
|
|||||||
baseFareMinor: number;
|
baseFareMinor: number;
|
||||||
displayCurrency?: string;
|
displayCurrency?: string;
|
||||||
displayAmountMinor?: number;
|
displayAmountMinor?: number;
|
||||||
|
available?: number;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
displayCurrency?: string;
|
displayCurrency?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user