mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1927,7 +1927,6 @@ export default function SeatsPage() {
|
||||
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
: null;
|
||||
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
|
||||
const seatFare = assignedSeat ? getSeatFare(assignedSeat) : null;
|
||||
const isActive = i === activePassengerIndex;
|
||||
const isClickable = i <= maxSelectableIndex;
|
||||
return (
|
||||
@@ -1977,11 +1976,6 @@ export default function SeatsPage() {
|
||||
>
|
||||
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
|
||||
</span>
|
||||
{assignedSeat && seatFare != null && (
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{currentSchedule?.displayCurrency || 'ETB'} {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface Schedule {
|
||||
baseFareMinor: number;
|
||||
displayCurrency?: string;
|
||||
displayAmountMinor?: number;
|
||||
available?: number;
|
||||
}>;
|
||||
}>;
|
||||
displayCurrency?: string;
|
||||
|
||||
Reference in New Issue
Block a user