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 2942625ca..355446544 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -64,7 +64,7 @@ export class SearchService {
const outbound = [...direct, ...transit];
- if (outbound.length === 0) {
+ if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') {
const alternativesOutbound = await this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
@@ -74,7 +74,7 @@ export class SearchService {
dto.nationality,
);
return {
- journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
+ journeyType: 'ONE_WAY',
outbound: [],
alternativeOutbound: alternativesOutbound,
requestedDate: dto.date,
@@ -110,19 +110,29 @@ export class SearchService {
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
);
- if (inbound.length === 0) {
- const alternativeInbound = await this.searchAlternatives(
- dto.destinationStationId,
- dto.originStationId,
- dto.returnDate ?? dto.date,
- dto.adultCount,
- dto.childCount,
- dto.nationality,
- );
- return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound };
+ const returnDate = dto.returnDate ?? dto.date;
+
+ if (outbound.length === 0 || inbound.length === 0) {
+ const [alternativeOutbound, alternativeInbound] = await Promise.all([
+ outbound.length === 0
+ ? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality)
+ : Promise.resolve([]),
+ inbound.length === 0
+ ? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality)
+ : Promise.resolve([]),
+ ]);
+ return {
+ journeyType: 'ROUND_TRIP',
+ outbound,
+ inbound,
+ alternativeOutbound,
+ alternativeInbound,
+ requestedDate: dto.date,
+ requestedReturnDate: returnDate,
+ };
}
- return { journeyType: 'ROUND_TRIP', outbound, inbound };
+ return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate };
}
return { journeyType: 'ONE_WAY', outbound };
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 495c394ee..b26dcb763 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -90,6 +90,7 @@ export class SeatsService {
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
+ coachTypeId: a.coach.coachType?.id ?? null,
coachTypeName,
isBedCoach,
bedCategory,
@@ -665,6 +666,7 @@ export class SeatsService {
return {
coachId: a.coach.id,
+ coachTypeId: a.coach.coachType?.id ?? null,
coachNumber: a.coach.number,
positionNumber: a.positionNumber,
coachTypeName: a.coach.coachType?.name ?? '',
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 b94b9ee14..77a18380f 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
@@ -143,17 +143,18 @@ export default function ResultsPage() {
}
}
- // For one-way, check if outbound has results
- // For round-trip, check if BOTH outbound and inbound have results
- const hasResults = isRoundTrip
- ? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
- : outboundSchedules.length > 0;
-
- // One-way searches that come back with an empty outbound list may still include
- // date-shifted alternatives from the API — surface those instead of a dead end.
- const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
- const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : [];
+ // Alternatives are surfaced whenever a leg returns no exact-date results.
+ const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : [];
+ const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : [];
const requestedDate: string = (results && results.requestedDate) || searchData.date;
+ const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || '';
+
+ const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
+ // Round-trip: show results view if either leg has exact results OR alternatives.
+ // One-way: need at least one outbound result.
+ const hasResults = isRoundTrip
+ ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0)
+ : outboundSchedules.length > 0;
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
@@ -687,8 +688,28 @@ export default function ResultsPage() {
}
if (!hasResults) {
- // ONE_WAY search with an explicit empty outbound list — surface any date-shifted
- // alternatives the API suggests instead of a dead-end "no trains found" screen.
+ const isRoundTripNoResults = isRoundTrip && !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && alternativeOutbound.length === 0 && alternativeInbound.length === 0;
+ if (isRoundTripNoResults) {
+ return (
+
+
+
+
+
+
+
+
No trains found
+
+ We couldn't find any trains for your round trip. Try adjusting your dates or route.
+
+
router.push(buildSearchUrl())} className="btn-primary">Modify search
+
+
+
+
+ );
+ }
+
if (isOneWayNoOutbound) {
const requestedDateLabel = requestedDate
? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy')
@@ -843,6 +864,26 @@ export default function ResultsPage() {
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
+ {outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
+
+
+
+
+
+
No trains available
+
+ No trains are available on {requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected date'} . This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
+
+
router.push(buildSearchUrl())} className="btn-primary">Change travel dates
+
+
+
Alternative Outbound Options
+
+
+ {alternativeOutbound.map((schedule: Schedule) => renderScheduleCard(schedule, true, true))}
+
+
+ )}
) : (
@@ -891,6 +932,26 @@ export default function ResultsPage() {
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
+ {inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
+
+
+
+
+
+
No trains available
+
+ No trains are available on {requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected return date'} . This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
+
+
router.push(buildSearchUrl())} className="btn-primary">Change travel dates
+
+
+
Alternative Return Options
+
+
+ {alternativeInbound.map((schedule: Schedule) => renderScheduleCard(schedule, false, true))}
+
+
+ )}
)
) : (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index b0a7e8fd8..ddf115084 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -172,6 +172,11 @@ export default function SeatsPage() {
searchCriteria,
bookingId,
packageName,
+ packageId,
+ priceTierId,
+ packageDepartureStationId,
+ packageDepartureStationName,
+ setPackageContext,
} = useBookingStore();
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
@@ -197,9 +202,12 @@ export default function SeatsPage() {
type: "info" as "warning" | "error" | "success" | "info",
onConfirm: undefined as (() => void) | undefined,
showCancel: false,
+ confirmText: "OK",
});
+ const [autoAssigningReturn, setAutoAssigningReturn] = useState(false);
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
+ const isPackageBooking = !!packageName;
const currentSchedule =
isRoundTrip && currentJourneyType === "inbound"
? inboundSchedule
@@ -306,8 +314,10 @@ export default function SeatsPage() {
return raw
.map((c: any, idx: number) => ({
id: c.id || c.coachId || String(idx),
+ coachId: c.coachId || c.id || null,
label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`,
type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""),
+ coachTypeName: String(c.coachTypeName || c.type || c.coachType || c.category || ""),
typeName: c.coachTypeName || c.coachType || c.category || c.type || "",
coachTypeId: c.coachTypeId ?? c.typeId ?? null,
remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? null,
@@ -316,6 +326,22 @@ export default function SeatsPage() {
.sort((a: any, b: any) => a.sequence - b.sequence);
}, [trainCoachesData]);
+ // Resolve the CoachType UUID for a preview-list coach. The preview API now returns
+ // coachTypeId directly; fall back to cross-referencing the seatmap data for older
+ // API versions that may not include it.
+ const resolveCoachTypeIdFromSeatmap = useCallback((previewCoach: any): string | null => {
+ if (previewCoach.coachTypeId) return previewCoach.coachTypeId;
+ // Fallback: match by label against the current seatmap coaches
+ const previewLabel = previewCoach.label || previewCoach.coachNumber || "";
+ const allSeatmapCoaches: any[] = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || [];
+ const match = allSeatmapCoaches.find(
+ (c: any) => (c.label || c.coachNumber || "") === previewLabel ||
+ c.id === previewCoach.coachId ||
+ c.id === previewCoach.id
+ );
+ return match?.coachTypeId ?? null;
+ }, [seatMapData]);
+
const isDiningCoachType = (type: string) => /dining|dpc/i.test(type);
// Looks up a coach type's lowest per-adult fare from the coach-type/fare data captured
@@ -363,17 +389,15 @@ export default function SeatsPage() {
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
const firstClass = matchedType.classes?.[0];
+ const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
const updatedSchedule = {
...(currentSchedule as any),
- selectedCoachTypeId: matchedType.coachTypeId || matchedType.coachId,
- selectedCoachTypeCode: matchedType.coachTypeCode,
- selectedCoachTypeName: matchedType.coachTypeName,
- selectedSeatClass: firstClass?.name || matchedType.coachTypeName,
- selectedSeatClassName: firstClass?.name || matchedType.coachTypeName,
- // review/page.tsx's fare-breakdown request reads THIS field (not
- // selectedSeatClassName) to resolve the seat class — must stay in sync or the
- // review page keeps pricing against the coach type the user switched away from.
- seatClassName: firstClass?.name || matchedType.coachTypeName,
+ selectedCoachTypeId: newCoachTypeId,
+ selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
+ selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
+ selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
+ selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
+ seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
};
@@ -382,6 +406,11 @@ export default function SeatsPage() {
setInboundSchedule(updatedSchedule);
} else if (isRoundTrip) {
setOutboundSchedule(updatedSchedule);
+ // For package bookings both legs always use the same coach type — mirror the
+ // switch to the inbound schedule so the auto-assign fetches the right seatmap.
+ if (isPackageBooking && inboundSchedule) {
+ setInboundSchedule({ ...(inboundSchedule as any), ...updatedSchedule, id: inboundSchedule.id });
+ }
} else {
setSelectedSchedule(updatedSchedule);
}
@@ -391,6 +420,19 @@ export default function SeatsPage() {
setSelectedCoach(null);
setPendingCoachLabel(coach.label);
setShowCoachPreview(false);
+
+ // For package bookings, sync the stored tier price with the new coach type's fare
+ // so the review page totals reflect the switched coach type.
+ if (isPackageBooking && packageId && newFare != null) {
+ setPackageContext(
+ packageId,
+ priceTierId ?? '',
+ newFare,
+ packageName ?? undefined,
+ packageDepartureStationId ?? undefined,
+ packageDepartureStationName ?? undefined,
+ );
+ }
};
// Same coach type as the one already loaded — no refetch needed, just bring this
@@ -401,12 +443,6 @@ export default function SeatsPage() {
setShowCoachPreview(false);
};
- // Coach card click handler for the Train Coach Preview: validates availability, then
- // immediately loads that coach's seat map (switching coach type if needed) — no price
- // confirmation here. Individual seats within a coach type/bed coach can still be priced
- // differently (e.g. Upper/Middle/Lower berths), so the fare confirmation instead happens
- // at the point of actually picking a seat (see handleSeatClick), once real seat data is
- // in view.
const handlePreviewCoachSelect = (coach: any) => {
if (coach.remainingSeats != null && coach.remainingSeats <= 0) {
setModalState({
@@ -416,31 +452,71 @@ export default function SeatsPage() {
type: "warning",
onConfirm: undefined,
showCancel: false,
+ confirmText: "OK",
});
return;
}
- const types = (currentSchedule as any)?.coachTypes || [];
- const matchedType = types.find(
- (ct: any) =>
- ct.coachTypeId === coach.coachTypeId ||
- ct.coachId === coach.coachTypeId ||
- ct.coachTypeCode === coach.type ||
- ct.coachTypeName === coach.type,
- );
- const isSameType =
- matchedType &&
- (matchedType.coachTypeId === coachTypeId || matchedType.coachId === coachTypeId);
+ // Resolve the real CoachType UUID by cross-referencing the seatmap data,
+ // since the preview API (/seats/coaches) returns physical coach IDs, not CoachType UUIDs.
+ const resolvedCoachTypeId = resolveCoachTypeIdFromSeatmap(coach);
- if (!matchedType || isSameType) {
- // Same coach type — just bring this physical coach's seat map into view.
+ // Same coach type as currently loaded — just scroll to it.
+ // Only trust the resolved UUID; name/code comparisons are unreliable across
+ // different API responses and cause false positives for package bookings.
+ const isSameType = !!resolvedCoachTypeId && resolvedCoachTypeId === coachTypeId;
+
+ if (isSameType) {
focusCoachInPlace(coach);
return;
}
- // Different coach type — switch to it and load its seat map; per-seat fare
- // confirmation (if any) happens once the user picks an actual seat.
- applyCoachTypeSwitch(coach, matchedType);
+ // Different coach type — build matchedType from coachTypes array or synthesise.
+ const types = (currentSchedule as any)?.coachTypes || [];
+ const matchedType = types.find(
+ (ct: any) =>
+ (resolvedCoachTypeId && (ct.coachTypeId === resolvedCoachTypeId || ct.coachId === resolvedCoachTypeId)) ||
+ (coach.coachTypeName && ct.coachTypeName === coach.coachTypeName) ||
+ (coach.type && (ct.coachTypeCode === coach.type || ct.coachTypeName === coach.type)),
+ ) ?? {
+ coachTypeId: resolvedCoachTypeId,
+ coachId: resolvedCoachTypeId,
+ coachTypeName: coach.coachTypeName || coach.typeName || coach.type || "",
+ coachTypeCode: coach.type || "",
+ classes: [],
+ };
+
+ const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
+ const currentFare = originalFareForCurrentLeg ?? (currentSchedule as any)?.baseFareAdult ?? null;
+
+ // Always confirm when switching to a different coach type — show fare difference
+ // if known, or a generic confirmation if fares can't be resolved.
+ if (newFare != null && currentFare != null && newFare !== currentFare) {
+ setModalState({
+ isOpen: true,
+ title: "Fare Will Change",
+ message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * 2).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * 2).toFixed(2)}). Continue?`,
+ type: "warning",
+ showCancel: true,
+ confirmText: "Switch Coach",
+ onConfirm: () => applyCoachTypeSwitch(coach, matchedType),
+ });
+ return;
+ }
+
+ // Same fare or fare unknown — still confirm the coach type switch.
+ const coachTypeName = matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "";
+ setModalState({
+ isOpen: true,
+ title: "Switch Coach Type",
+ message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
+ newFare != null ? ` Fare: ETB ${(newFare / 100 * 2).toFixed(2)} per adult.` : " This will have a fare change."
+ }`,
+ type: "info",
+ showCancel: true,
+ confirmText: "Switch Coach",
+ onConfirm: () => applyCoachTypeSwitch(coach, matchedType),
+ });
};
const holdMutation = useMutation({
@@ -693,7 +769,9 @@ export default function SeatsPage() {
const newSeat = validSeats?.find((s: any) => s.id === seatId);
const newFare = newSeat ? getSeatFare(newSeat) : null;
- if (newFare != null) {
+ // Package bookings: fare-change warning on individual seat clicks is suppressed.
+ // The only fare-change confirmation is when switching coach type via Train Coach Preview.
+ if (newFare != null && !isPackageBooking) {
let referenceFare: number | null = null;
let referenceLabel = "the fare you originally selected";
@@ -723,9 +801,10 @@ export default function SeatsPage() {
setModalState({
isOpen: true,
title: "Fare Will Change",
- message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`,
+ message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * 2).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`,
type: "warning",
showCancel: true,
+ confirmText: "Continue",
onConfirm: () => commitSeatAssignment(seatId),
});
return;
@@ -764,6 +843,152 @@ export default function SeatsPage() {
};
});
setPassengers(updatedPassengers);
+
+ if (isPackageBooking && inboundSchedule) {
+ setAutoAssigningReturn(true);
+ try {
+ // Package bookings always use the same coach type for both legs — use the
+ // outbound's (just-confirmed) coachTypeId so a prior coach-type switch is
+ // reflected in the inbound seatmap fetch even if inboundSchedule wasn't updated.
+ const inboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId || (inboundSchedule as any).selectedCoachTypeId;
+ const inboundOriginId = (inboundSchedule as any).originStationId || searchCriteria?.destinationStationId;
+ const inboundDestId = (inboundSchedule as any).destinationStationId || searchCriteria?.originStationId;
+
+ // Fetch the full outbound seatmap to resolve seat number/bedPosition by ID —
+ // validSeats only holds the currently-expanded coach and may be empty.
+ const outboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId;
+ const outboundOriginId = (currentSchedule as any)?.originStationId || searchCriteria?.originStationId;
+ const outboundDestId = (currentSchedule as any)?.destinationStationId || searchCriteria?.destinationStationId;
+ const outboundMapData: any = await apiClient.get(
+ `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${outboundCoachTypeId}&journeyDirection=OUTBOUND${outboundOriginId ? `&originStationId=${outboundOriginId}` : ''}${outboundDestId ? `&destinationStationId=${outboundDestId}` : ''}`
+ );
+ const outboundCoaches: any[] = (outboundMapData as any)?.coaches || (outboundMapData as any)?.data?.coaches || [];
+ const allOutboundSeats: any[] = [];
+ outboundCoaches.forEach((c: any) => {
+ if (c.rooms?.length > 0) {
+ c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allOutboundSeats.push(b)));
+ } else {
+ (c.seats || []).forEach((s: any) => allOutboundSeats.push(s));
+ }
+ });
+
+ const inboundMapData: any = await apiClient.get(
+ `/seats/seatmap/${inboundSchedule.id}?coachTypeId=${inboundCoachTypeId}&journeyDirection=RETURN${inboundOriginId ? `&originStationId=${inboundOriginId}` : ''}${inboundDestId ? `&destinationStationId=${inboundDestId}` : ''}`
+ );
+ const inboundCoaches: any[] = (inboundMapData as any)?.coaches || (inboundMapData as any)?.data?.coaches || [];
+
+ const allInboundSeats: any[] = [];
+ inboundCoaches.forEach((c: any) => {
+ const coachLabel = c.label || c.name || c.coachNumber || '';
+ if (c.rooms?.length > 0) {
+ c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allInboundSeats.push({ ...b, _coachLabel: coachLabel })));
+ } else {
+ (c.seats || []).forEach((s: any) => allInboundSeats.push({ ...s, _coachLabel: coachLabel }));
+ }
+ });
+
+ const claimedIds = new Set();
+ const inboundSeatMap: Record = {};
+
+ for (const i of seatEligibleIndices) {
+ // Resolve outbound seat from the full seatmap fetch (not validSeats which
+ // only holds the currently-expanded coach and is often empty).
+ const outboundSeat = allOutboundSeats.find((s: any) => s.id === seatIds[i]);
+ const outboundBase = outboundSeat ? (outboundSeat.number || outboundSeat.label || outboundSeat.seatNumber || '') : '';
+ const outboundBedPos: string | null = outboundSeat?.bedPosition || null;
+
+ // Priority 1: exact same seat number + same bed position
+ // Priority 2: same bed position, any available seat
+ // Priority 3: any available seat (fallback)
+ const match =
+ allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && (s.number || s.label || s.seatNumber || '') === outboundBase && (!outboundBedPos || s.bedPosition === outboundBedPos)) ||
+ allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && outboundBedPos && s.bedPosition === outboundBedPos) ||
+ allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id));
+
+ if (match) {
+ inboundSeatMap[i] = match.id;
+ claimedIds.add(match.id);
+ }
+ }
+
+ if (claimedIds.size < seatEligibleIndices.length) {
+ // Not enough inbound seats found — fall through to manual inbound selection
+ setAutoAssigningReturn(false);
+ setCurrentJourneyType("inbound");
+ setPassengerSeatMap({});
+ setActivePassengerIndex(seatEligibleIndices[0] ?? 0);
+ setSelectedCoach(null);
+ return;
+ }
+
+ // Hold inbound seats directly — holdMutation reads currentJourneyType
+ // which is still "outbound" at this point, so we call the API directly.
+ const inboundHoldData: any = await apiClient.post('/seats/hold', {
+ scheduleId: inboundSchedule.id,
+ originStationId: inboundOriginId,
+ destinationStationId: inboundDestId,
+ journeyDirection: 'RETURN',
+ passengers: seatEligibleIndices.map((i, offset) => ({
+ passengerId: `temp-${Date.now()}-${offset}`,
+ seatId: inboundSeatMap[i],
+ })),
+ });
+
+ const currentHold = useBookingStore.getState().seatHold;
+ setSeatHold({
+ holdId: currentHold?.holdId || '',
+ expiresAt: currentHold?.expiresAt || '',
+ returnHoldId: inboundHoldData.holdId || inboundHoldData.id,
+ returnExpiresAt: inboundHoldData.expiresAt,
+ });
+
+ const withInbound = updatedPassengers.map((p, i) => {
+ const inboundSeatId = inboundSeatMap[i];
+ const inboundSeatData = inboundSeatId ? allInboundSeats.find((s: any) => s.id === inboundSeatId) : undefined;
+ return {
+ ...p,
+ inboundSeatId,
+ inboundSeatNumber: inboundSeatData ? buildSeatLabel(inboundSeatData) : '',
+ inboundCoachNumber: inboundSeatData?._coachLabel || '',
+ inboundSeatFareMinor: undefined,
+ inboundBedPosition: inboundSeatData?.bedPosition || undefined,
+ };
+ });
+ setPassengers(withInbound);
+
+ // Update the stored tier price with the actual berth fare so the review page
+ // reflects the correct price when the user picks Upper/Middle/Lower berths
+ // (which are priced differently within the same coach type).
+ if (packageId) {
+ const firstEligibleIdx = seatEligibleIndices[0];
+ const outboundSeat = firstEligibleIdx != null
+ ? allOutboundSeats.find((s: any) => s.id === seatIds[firstEligibleIdx])
+ : null;
+ const berthFare = outboundSeat ? getSeatFare(outboundSeat) : null;
+ if (berthFare != null) {
+ setPackageContext(
+ packageId,
+ priceTierId ?? '',
+ berthFare,
+ packageName ?? undefined,
+ packageDepartureStationId ?? undefined,
+ packageDepartureStationName ?? undefined,
+ );
+ }
+ }
+
+ router.push('/booking/review');
+ return;
+ } catch {
+ // Auto-assign failed — fall through to manual inbound selection
+ setAutoAssigningReturn(false);
+ setCurrentJourneyType("inbound");
+ setPassengerSeatMap({});
+ setActivePassengerIndex(seatEligibleIndices[0] ?? 0);
+ setSelectedCoach(null);
+ return;
+ }
+ }
} catch (error: any) {
setModalState({
isOpen: true,
@@ -774,6 +999,7 @@ export default function SeatsPage() {
type: "error",
onConfirm: undefined,
showCancel: false,
+ confirmText: "OK",
});
return;
}
@@ -818,6 +1044,7 @@ export default function SeatsPage() {
type: "error",
onConfirm: undefined,
showCancel: false,
+ confirmText: "OK",
});
return;
}
@@ -841,6 +1068,7 @@ export default function SeatsPage() {
type: "warning",
onConfirm: undefined,
showCancel: false,
+ confirmText: "OK",
});
return;
}
@@ -1323,7 +1551,9 @@ export default function SeatsPage() {
const isCurrentType =
!!coachTypeId &&
(coach.coachTypeId === coachTypeId ||
+ coach.coachId === coachTypeId ||
coach.type === (currentSchedule as any)?.selectedCoachTypeCode ||
+ coach.coachTypeName === (currentSchedule as any)?.selectedCoachTypeName ||
coach.type === (currentSchedule as any)?.selectedCoachTypeName);
return (
@@ -1503,14 +1733,16 @@ export default function SeatsPage() {
- {holdMutation.isPending
- ? "Holding seats..."
- : isRoundTrip && currentJourneyType === "outbound"
- ? "Continue to Return Seats"
- : "Continue"}
+ {autoAssigningReturn
+ ? "Assigning return seats..."
+ : holdMutation.isPending
+ ? "Holding seats..."
+ : isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
+ ? "Continue to Return Seats"
+ : "Continue"}
setModalState({ ...modalState, isOpen: false })}
+ onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
title={modalState.title}
message={modalState.message}
type={modalState.type}
onConfirm={modalState.onConfirm}
showCancel={modalState.showCancel}
- confirmText={modalState.showCancel ? "Switch Coach" : "OK"}
+ confirmText={modalState.confirmText}
/>
{/* Train Coach Preview */}
@@ -1561,7 +1793,7 @@ export default function SeatsPage() {
{/* Desktop: right-side panel, no backdrop — seat selection stays fully usable */}
@@ -1612,11 +1844,13 @@ export default function SeatsPage() {
- {isRoundTrip
- ? currentJourneyType === "outbound"
- ? packageName ? `Select Outbound Seats for ${packageName}` : "Select Outbound Seats"
- : packageName ? `Select Return Seats for ${packageName}` : "Select Return Seats"
- : "Select Seats"}
+ {isPackageBooking
+ ? `Select Seats for ${packageName}`
+ : isRoundTrip
+ ? currentJourneyType === "outbound"
+ ? "Select Outbound Seats"
+ : "Select Return Seats"
+ : "Select Seats"}
{!allSeatsAssigned && (
diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
index 3e4dc04df..c7ef3fe52 100644
--- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
@@ -365,6 +365,7 @@ const PKG_CHILDREN_PER_ADULT = 5;
function PassengerCountModal({
tier,
+ minPriceMinor,
onClose,
onConfirm,
loading,
@@ -373,6 +374,7 @@ function PassengerCountModal({
stations,
}: {
tier: PriceTier;
+ minPriceMinor: number;
onClose: () => void;
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
loading: boolean;
@@ -389,7 +391,7 @@ function PassengerCountModal({
const freeChildren = Math.min(childCount, adultCount);
const paidChildren = Math.max(0, childCount - adultCount);
// Only paid children need seats; free children share with an adult
- const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier;
+ const totalMinor = (adultCount * minPriceMinor + paidChildren * minPriceMinor) * priceMultiplier;
return (
<>
@@ -406,7 +408,7 @@ function PassengerCountModal({
Coach type
{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}
-
{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)
+
{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)
@@ -442,7 +444,7 @@ function PassengerCountModal({
)}
- Total{priceMultiplier === 2 ? ' (round-trip)' : ''}
+ Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''}
{formatPrice(totalMinor, tier.currency)}
@@ -557,6 +559,15 @@ export default function PackageDetailPage() {
selectedSeatClassName: ctx.seatClassName ?? "",
seatClassName: ctx.seatClassName ?? "",
selectedCoachTypeId: ctx.coachTypeId ?? "",
+ selectedCoachTypeCode: ctx.coachTypeCode ?? "",
+ selectedCoachTypeName: ctx.coachTypeName ?? "",
+ coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({
+ coachId: g.coachTypeId,
+ coachTypeId: g.coachTypeId,
+ coachTypeName: g.coachTypeName,
+ coachTypeCode: g.coachTypeCode,
+ classes: [{ name: g.coachTypeName, baseFareMinor: g.minPrice }],
+ })),
});
const outboundSched = toSchedule(ctx.outboundSchedule);
@@ -648,6 +659,7 @@ export default function PackageDetailPage() {
{passengerModalOpen && representativeTier && (
{ setPassengerModalOpen(false); setBookingContextError(null); }}
onConfirm={handleBookNow}
loading={bookingContextLoading}