From f424ca9f58d888292c3d00bdf7cd9be3005932a6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 7 Jul 2026 15:17:25 +0300 Subject: [PATCH] Package seat selection updates, round trip alternative trips updates --- .../src/modules/search/search.service.ts | 36 +- .../src/modules/seats/seats.service.ts | 2 + .../portal/src/app/booking/results/page.tsx | 85 ++++- .../portal/src/app/booking/seats/page.tsx | 328 +++++++++++++++--- .../portal/src/app/packages/[id]/page.tsx | 18 +- 5 files changed, 394 insertions(+), 75 deletions(-) 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. +

+ +
+
+
+
+ ); + } + 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. +

+ +
+
+

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. +

+ +
+
+

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() {

- {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}