diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 0e27eb989..29c9a6798 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -590,8 +590,22 @@ export class GuestBookingService { // Release the reservation using the SAME scope it was created with (global vs // schedule-scoped) — unblockSeat already correctly resets Seat.status for a global - // block; reimplementing that here would risk missing that reset. + // block; reimplementing that here would risk missing that reset. This alone leaves a + // window where the seat has no SeatBlock, no SeatHold, and no JourneySegment (the + // latter is only created on payment success — see PaymentsService.createJourneySegments) + // — i.e. fully available to the public — the instant this returns, since confirmSeats() + // is a no-op with no existing hold to extend. holdSeats() immediately re-reserves the + // seat with the same createdBy segment-range metadata the search/hold-conflict checks + // already rely on (getSeatAvailabilityMap); confirmSeats() then extends that hold to the + // real payment deadline (same mechanism createGuestOneWayBooking uses), so the seat stays + // unavailable to everyone else until the passenger pays or the hold/booking expires. await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined); + await this.seatsService.holdSeats({ + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: [{ passengerId: guestPassengerId, seatId }], + }); await this.seatsService.confirmSeats([seatId]); this.eventEmitter.emit('booking.created', { booking }); 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 0c905acdd..9c368a976 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -12,6 +12,7 @@ import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils"; import { Currency, Prisma } from "@prisma/client"; +import { Passenger } from "@edr/types"; const POINTS_TO_MINOR = 10; @@ -102,19 +103,23 @@ export class SearchService { const outbound = [...direct, ...transit]; if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") { - const alternativesOutbound = await this.searchAlternatives( - dto.originStationId, - dto.destinationStationId, - dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); + const [alternativesOutbound, outboundReason] = await Promise.all([ + this.searchAlternatives( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date), + ]); return { journeyType: "ONE_WAY", outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, + outboundReason, }; } @@ -157,7 +162,7 @@ export class SearchService { const returnDate = dto.returnDate ?? dto.date; if (outbound.length === 0 || inbound.length === 0) { - const [alternativeOutbound, alternativeInbound] = await Promise.all([ + const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([ outbound.length === 0 ? this.searchAlternatives( dto.originStationId, @@ -178,6 +183,12 @@ export class SearchService { dto.nationality, ) : Promise.resolve([]), + outbound.length === 0 + ? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date) + : Promise.resolve(undefined), + inbound.length === 0 + ? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate) + : Promise.resolve(undefined), ]); return { journeyType: "ROUND_TRIP", @@ -187,6 +198,8 @@ export class SearchService { alternativeInbound, requestedDate: dto.date, requestedReturnDate: returnDate, + outboundReason, + inboundReason, }; } @@ -344,6 +357,101 @@ export class SearchService { ); } + /** + * Only called when searchSchedules/searchTransitOptions found zero bookable results for a + * leg — classifies WHY, cheaply, by re-querying without the filters that already excluded + * everything. Priority order (most specific/actionable first): a station pair EDR never + * connects at all beats "nothing on this exact date", which beats "something exists but + * every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode. + */ + private async classifyEmptySearch( + originStationId: string, + destinationStationId: string, + dateStr: string, + ): Promise { + const [origin, destination] = await Promise.all([ + this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }), + this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }), + ]); + const originStationName = origin?.name ?? "the origin station"; + const destinationStationName = destination?.name ?? "the destination station"; + const withCode = (code: Passenger.SearchEmptyReasonCode) => ({ + code, + originStationName, + destinationStationName, + }); + + // 1. Does any active route connect these two stations, in this direction, at all — + // ignoring date entirely? + const candidateRoutes = await this.prisma.route.findMany({ + where: { active: true, stops: { some: { stationId: originStationId } } }, + select: { stops: { select: { stationId: true, sequence: true } } }, + }); + const routeExists = candidateRoutes.some((r) => { + const o = r.stops.find((s) => s.stationId === originStationId); + const d = r.stops.find((s) => s.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); + if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + + // 2. A route exists — is there any schedule at all on the requested date for this pair + // (regardless of status/package/coach/cutoff — those are checked next)? + const [y, m, d] = dateStr.split("-").map(Number); + const date = new Date( + `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`, + ); + const nextDay = new Date( + `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, + ); + const dayCandidates = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } }, + select: { + status: true, + isPackageOnly: true, + departureAt: true, + route: { + select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, + }, + stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + coachAssignments: { select: { id: true } }, + }, + }); + const sameDayForPair = dayCandidates.filter((s) => { + const o = s.stopTimes.find((st) => st.stationId === originStationId); + const dst = s.stopTimes.find((st) => st.stationId === destinationStationId); + return !!o && !!dst && o.sequence < dst.sequence; + }); + if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); + + // 3. Schedules exist that date — narrow to ones that would otherwise be bookable + // (right status, not package-only, has at least one coach assigned). + const bookable = sameDayForPair.filter( + (s) => + (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && + !s.isPackageOnly && + s.coachAssignments.length > 0, + ); + if (bookable.length === 0) { + if (sameDayForPair.every((s) => s.status === "CANCELLED")) + return withCode(Passenger.SearchEmptyReasonCode.Cancelled); + if (sameDayForPair.every((s) => s.isPackageOnly)) + return withCode(Passenger.SearchEmptyReasonCode.PackageOnly); + return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); + } + + // 4. Bookable schedules exist — did every one of them already pass its check-in cutoff + // for this origin stop? + const allCutoffPassed = bookable.every((s) => { + const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null; + return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime(); + }); + if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed); + + // 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason + // searchSchedules dropped it is zero/insufficient seat availability. + return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); + } + // ── Transit search ───────────────────────────────────────────────────────── private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts index cb347092a..75f822289 100644 --- a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -294,6 +294,37 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { expect(byToken.schedule.origin.id).toBe(IDS.stationA); }); + it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => { + // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats() + // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and + // no JourneySegment (only created on payment success) the instant the payment link went + // out — fully bookable by the general public before the traveler had even paid. + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-4", + ); + expect(result.booking.status).toBe("PENDING_PAYMENT"); + + // A member of the public trying to hold the exact same seat/leg must be rejected — + // proves the seat is covered by a real SeatHold (or equivalent), not silently free. + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "someone-else", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/already (held|booked)/i); + }); + it("requires a phone number for a PASSENGER booking", async () => { await resetAndSeedCore(harness.prisma); const dep = new Date(Date.now() + 3 * 60 * 60_000); 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 2c7d37f0f..e26df33ed 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,7 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format"; import { formatFare } from "@/utils/fare-utils"; import { useState, useEffect } from "react"; import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar"; +import { Passenger } from "@edr/types"; // 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. @@ -35,6 +36,73 @@ const getCoachIcon = (typeName: string) => { return Armchair; }; +function formatSearchDate(dateStr: string): string { + if (!dateStr) return "your selected date"; + try { + return format(new Date(`${dateStr}T00:00:00`), "MMM d, yyyy"); + } catch { + return dateStr; + } +} + +// Turns the /search response's outboundReason/inboundReason (absent on older responses, +// or when the emptiness cause couldn't be classified) into copy for the empty-state cards +// below. `showAlternatives` gates whether an alternative-dates calendar makes sense — a +// station pair EDR never serves can't be fixed by picking a different date. +function emptyReasonCopy( + reason: Passenger.ISearchEmptyReason | undefined, + dateStr: string, +): { title: string; message: string; showAlternatives: boolean } { + const date = formatSearchDate(dateStr); + if (!reason) { + return { + title: "No trains available", + message: `No trains available on ${date}. Please choose another date below.`, + showAlternatives: true, + }; + } + const { code, originStationName, destinationStationName } = reason; + switch (code) { + case Passenger.SearchEmptyReasonCode.NoRoute: + return { + title: "Route not available", + message: `EDR doesn't currently run trains between ${originStationName} and ${destinationStationName}.`, + showAlternatives: false, + }; + case Passenger.SearchEmptyReasonCode.FullyBooked: + return { + title: "Fully booked", + message: `This train is fully booked on ${date}. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.Cancelled: + return { + title: "Departure cancelled", + message: `The ${date} departure between ${originStationName} and ${destinationStationName} was cancelled. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.CheckinClosed: + return { + title: "Booking closed for today", + message: `Booking for the ${date} departure has closed. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.PackageOnly: + return { + title: "Only a travel package is available", + message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.NoScheduleOnDate: + default: + return { + title: "No trains available", + message: `No trains available on ${date}. Please choose another date below.`, + showAlternatives: true, + }; + } +} + export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); @@ -1097,18 +1165,35 @@ export default function ResultsPage() { (results?.alternativeOutbound || []).length === 0 && (results?.alternativeInbound || []).length === 0; if (isRoundTripNoResults) { + const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date); + const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date); + const sameReason = + !!results?.outboundReason && + results?.outboundReason?.code === results?.inboundReason?.code; return (
-
-
- - No trains found for your selected dates or route. +
+
+
+ + + {sameReason + ? outboundCopy.message + : "No trains found for your selected dates or route."} + +
+
- + {!sameReason && (results?.outboundReason || results?.inboundReason) && ( +
+

Outbound: {outboundCopy.message}

+

Return: {inboundCopy.message}

+
+ )}
@@ -1125,6 +1210,8 @@ export default function ResultsPage() { const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0; const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined); const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined); + const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date); + const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date); return (
@@ -1137,50 +1224,62 @@ export default function ResultsPage() {

No trains available

-

- No trains available on your selected dates. Please choose another - date below. -

-
- {alternativeOutbound.length > 0 ? ( - { - if (!bothCalendarsAvailable) { - pushResultsWithDates({ date: format(date, "yyyy-MM-dd") }); - return; - } - setPendingOutboundDate(date); - if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined); - }} - /> - ) : ( -

- No alternative outbound dates found nearby. +

+
+

+ Outbound — {outboundCopy.title}

- )} - {alternativeInbound.length > 0 ? ( - { - if (!bothCalendarsAvailable) { - pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") }); - return; - } - setPendingInboundDate(date); - }} - /> - ) : ( -

- No alternative return dates found nearby. +

{outboundCopy.message}

+ {outboundCopy.showAlternatives ? ( + alternativeOutbound.length > 0 ? ( + { + if (!bothCalendarsAvailable) { + pushResultsWithDates({ date: format(date, "yyyy-MM-dd") }); + return; + } + setPendingOutboundDate(date); + if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined); + }} + /> + ) : ( +

+ No alternative outbound dates found nearby. +

+ ) + ) : null} +
+
+

+ Return — {inboundCopy.title}

- )} +

{inboundCopy.message}

+ {inboundCopy.showAlternatives ? ( + alternativeInbound.length > 0 ? ( + { + if (!bothCalendarsAvailable) { + pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") }); + return; + } + setPendingInboundDate(date); + }} + /> + ) : ( +

+ No alternative return dates found nearby. +

+ ) + ) : null} +
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (

@@ -1201,6 +1300,10 @@ export default function ResultsPage() { } if (isOneWayNoOutbound) { + const { title, message, showAlternatives } = emptyReasonCopy( + results?.outboundReason, + searchData.date, + ); return (

{renderClassModal()} @@ -1211,26 +1314,34 @@ export default function ResultsPage() {

- No trains available + {title}

- No trains available on your selected date. Please choose another - date below. + {message}

- {alternativeOutbound.length > 0 ? ( - pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })} - /> + {showAlternatives ? ( + alternativeOutbound.length > 0 ? ( + pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })} + /> + ) : ( + + ) ) : ( )}
diff --git a/e2e-ui/specs/portal/search-empty-reasons.spec.ts b/e2e-ui/specs/portal/search-empty-reasons.spec.ts new file mode 100644 index 000000000..1f68e2022 --- /dev/null +++ b/e2e-ui/specs/portal/search-empty-reasons.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, STATIONS, resultsUrl, sampleDepartDate, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Portal search empty-state — asserts /search's outboundReason drives a message specific to WHY + * zero results came back, instead of the old one-size-fits-all "No trains available" copy (see + * SearchService.classifyEmptySearch + results/page.tsx's emptyReasonCopy). Each test isolates its + * own route/station/schedule (never mutates the shared seed fixtures) so it can run alongside the + * rest of the portal suite without disturbing other specs' assumptions about the seeded trip. + */ + +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +/** Days-from-now at 06:00Z, matching seed-ui's sampleDepartAt() convention (same-day in Addis TZ). */ +function daysFromNowDate(days: number): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + days); + d.setUTCHours(6, 0, 0, 0); + return d.toISOString().slice(0, 10); +} + +/** resultsUrl() hardcodes STATIONS.A→C and sampleDepartDate() — these tests need other pairs/dates. */ +function customResultsUrl(originId: string, destinationId: string, date: string, adults = 1) { + const p = new URLSearchParams({ + origin: originId, + destination: destinationId, + date, + tripType: "ONE_WAY", + adults: String(adults), + children: "0", + nationality: "ETHIOPIAN", + }); + return `/booking/results?${p.toString()}`; +} + +test("empty-state: fully booked shows a fully-booked message, not a generic one", async ({ page }) => { + // The seeded trip (UI_IDS.schedule) has 48 seats total — asking for far more than that guarantees + // zero seat classes satisfy the party size, without needing to actually consume real seats. + await page.goto(resultsUrl({ adults: 500 }), { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "Fully booked" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/fully booked on/i)).toBeVisible(); +}); + +test("empty-state: no schedule that day shows the generic no-trains message", async ({ page }) => { + // ROUTE_ID/STATIONS A→C is a real, active route — just pick a date far enough out that no + // schedule was ever created for it. + const farDate = daysFromNowDate(300); + await page.goto(customResultsUrl(STATIONS.A, STATIONS.C, farDate), { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "No trains available" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/no trains available on/i)).toBeVisible(); +}); + +test("empty-state: a cancelled departure says so, not \"no trains available\"", async ({ page, request }) => { + const routeRes = await request.post(`${API_URL}/routes`, { + headers: auth(), + data: { + code: `E2E-EMPTY-CANCELLED-${Date.now()}`, + name: "E2E Empty Cancelled Route", + effectiveFrom: "2020-01-01T00:00:00Z", + stops: [ + { stationId: STATIONS.A, sequence: 1, distanceKm: 0 }, + { stationId: STATIONS.C, sequence: 2, distanceKm: 100 }, + ], + }, + }); + expect(routeRes.ok()).toBeTruthy(); + const route = (await routeRes.json())?.data; + + const templateRes = await request.put(`${API_URL}/routes/${route.id}/coaches`, { + headers: auth(), + data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] }, + }); + expect(templateRes.ok()).toBeTruthy(); + + const date = daysFromNowDate(60); + const dep = new Date(`${date}T06:00:00Z`); + const arr = new Date(dep.getTime() + 4 * 3600_000); + const scheduleRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId: route.id, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(scheduleRes.ok()).toBeTruthy(); + const schedule = (await scheduleRes.json())?.data; + + const cancelRes = await request.patch(`${API_URL}/schedules/${schedule.id}/status`, { + headers: auth(), + data: { status: "CANCELLED" }, + }); + expect(cancelRes.ok()).toBeTruthy(); + + await page.goto(customResultsUrl(STATIONS.A, STATIONS.C, date), { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("Departure cancelled")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/was cancelled/i)).toBeVisible(); +}); + +test("empty-state: an unconnected station pair says EDR doesn't run there, with no date picker", async ({ + page, + request, +}) => { + const stationRes = await request.post(`${API_URL}/stations`, { + headers: auth(), + data: { code: `E2E-ISO-${Date.now() % 100000}`, name: "E2E Isolated Station", city: "Nowhere" }, + }); + expect(stationRes.ok()).toBeTruthy(); + const isolatedStation = (await stationRes.json())?.data; + + await page.goto(customResultsUrl(STATIONS.A, isolatedStation.id, sampleDepartDate()), { + waitUntil: "domcontentloaded", + }); + + await expect(page.getByText("Route not available")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/doesn't currently run trains between/i)).toBeVisible(); + // NO_ROUTE is not fixable by picking another date — no calendar/"Change Date" affordance. + await expect(page.getByText(/change date/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /modify search/i })).toBeVisible(); +}); diff --git a/packages/types/src/passenger/index.ts b/packages/types/src/passenger/index.ts index e7f0d8ccd..dff769f00 100644 --- a/packages/types/src/passenger/index.ts +++ b/packages/types/src/passenger/index.ts @@ -34,6 +34,34 @@ export enum ScheduleStatus { Delayed = "DELAYED", } +/** + * Why a /search leg (outbound or inbound) came back with zero bookable schedules. + * Priority order applied by the API when classifying: NoRoute > NoScheduleOnDate > + * Cancelled > PackageOnly > CheckinClosed > FullyBooked (see search.service.ts + * classifyEmptySearch). The frontend uses this to show a specific empty-state + * message instead of a generic "no trains available". + */ +export enum SearchEmptyReasonCode { + /** No route (in either direction) ever connects these two stations. */ + NoRoute = "NO_ROUTE", + /** A route connects these stations, but no schedule lands on the requested date at all. */ + NoScheduleOnDate = "NO_SCHEDULE_ON_DATE", + /** Every schedule for this pair on this date was cancelled. */ + Cancelled = "CANCELLED", + /** Every schedule for this pair on this date is package-only (excluded from ticket search). */ + PackageOnly = "PACKAGE_ONLY", + /** A bookable schedule exists, but its check-in cutoff has already passed for every option. */ + CheckinClosed = "CHECKIN_CLOSED", + /** A bookable, still-open schedule exists but has no seats left for the requested party. */ + FullyBooked = "FULLY_BOOKED", +} + +export interface ISearchEmptyReason { + code: SearchEmptyReasonCode; + originStationName: string; + destinationStationName: string; +} + export enum PaymentStatus { Pending = "PENDING", Paid = "PAID",