Package seat selection updates, round trip alternative trips updates

This commit is contained in:
Stephanos A
2026-07-07 15:17:25 +03:00
parent 30661a2699
commit f424ca9f58
5 changed files with 394 additions and 75 deletions

View File

@@ -64,7 +64,7 @@ export class SearchService {
const outbound = [...direct, ...transit]; const outbound = [...direct, ...transit];
if (outbound.length === 0) { if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') {
const alternativesOutbound = await this.searchAlternatives( const alternativesOutbound = await this.searchAlternatives(
dto.originStationId, dto.originStationId,
dto.destinationStationId, dto.destinationStationId,
@@ -74,7 +74,7 @@ export class SearchService {
dto.nationality, dto.nationality,
); );
return { return {
journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', journeyType: 'ONE_WAY',
outbound: [], outbound: [],
alternativeOutbound: alternativesOutbound, alternativeOutbound: alternativesOutbound,
requestedDate: dto.date, requestedDate: dto.date,
@@ -110,19 +110,29 @@ export class SearchService {
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
); );
if (inbound.length === 0) { const returnDate = dto.returnDate ?? dto.date;
const alternativeInbound = await this.searchAlternatives(
dto.destinationStationId, if (outbound.length === 0 || inbound.length === 0) {
dto.originStationId, const [alternativeOutbound, alternativeInbound] = await Promise.all([
dto.returnDate ?? dto.date, outbound.length === 0
dto.adultCount, ? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality)
dto.childCount, : Promise.resolve([]),
dto.nationality, inbound.length === 0
); ? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality)
return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; : 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 }; return { journeyType: 'ONE_WAY', outbound };

View File

@@ -90,6 +90,7 @@ export class SeatsService {
label: a.coach.number, label: a.coach.number,
mode: a.coach.status, mode: a.coach.status,
name: `Coach ${a.coach.number}`, name: `Coach ${a.coach.number}`,
coachTypeId: a.coach.coachType?.id ?? null,
coachTypeName, coachTypeName,
isBedCoach, isBedCoach,
bedCategory, bedCategory,
@@ -665,6 +666,7 @@ export class SeatsService {
return { return {
coachId: a.coach.id, coachId: a.coach.id,
coachTypeId: a.coach.coachType?.id ?? null,
coachNumber: a.coach.number, coachNumber: a.coach.number,
positionNumber: a.positionNumber, positionNumber: a.positionNumber,
coachTypeName: a.coach.coachType?.name ?? '', coachTypeName: a.coach.coachType?.name ?? '',

View File

@@ -143,17 +143,18 @@ export default function ResultsPage() {
} }
} }
// For one-way, check if outbound has results // Alternatives are surfaced whenever a leg returns no exact-date results.
// For round-trip, check if BOTH outbound and inbound have results const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : [];
const hasResults = isRoundTrip const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : [];
? (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 || []) : [];
const requestedDate: string = (results && results.requestedDate) || searchData.date; 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) => { const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
@@ -687,8 +688,28 @@ export default function ResultsPage() {
} }
if (!hasResults) { if (!hasResults) {
// ONE_WAY search with an explicit empty outbound list — surface any date-shifted const isRoundTripNoResults = isRoundTrip && !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && alternativeOutbound.length === 0 && alternativeInbound.length === 0;
// alternatives the API suggests instead of a dead-end "no trains found" screen. if (isRoundTripNoResults) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains for your round trip. Try adjusting your dates or route.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Modify search</button>
</div>
</div>
</div>
</div>
);
}
if (isOneWayNoOutbound) { if (isOneWayNoOutbound) {
const requestedDateLabel = requestedDate const requestedDateLabel = requestedDate
? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy')
@@ -843,6 +864,26 @@ export default function ResultsPage() {
<div className="space-y-4"> <div className="space-y-4">
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))} {outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
</div> </div>
{outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">No trains available</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected date'}</span>. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Change travel dates</button>
</div>
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">Alternative Outbound Options</h3>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule: Schedule) => renderScheduleCard(schedule, true, true))}
</div>
</div>
)}
</div> </div>
) : ( ) : (
<div> <div>
@@ -891,6 +932,26 @@ export default function ResultsPage() {
<div className="space-y-4" id="inbound-section"> <div className="space-y-4" id="inbound-section">
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))} {inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
</div> </div>
{inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">No trains available</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected return date'}</span>. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Change travel dates</button>
</div>
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">Alternative Return Options</h3>
</div>
<div className="space-y-4">
{alternativeInbound.map((schedule: Schedule) => renderScheduleCard(schedule, false, true))}
</div>
</div>
)}
</div> </div>
) )
) : ( ) : (

View File

@@ -172,6 +172,11 @@ export default function SeatsPage() {
searchCriteria, searchCriteria,
bookingId, bookingId,
packageName, packageName,
packageId,
priceTierId,
packageDepartureStationId,
packageDepartureStationName,
setPackageContext,
} = useBookingStore(); } = useBookingStore();
// Maps passenger index -> assigned seat id. A passenger can only get a seat while // 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. // 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", type: "info" as "warning" | "error" | "success" | "info",
onConfirm: undefined as (() => void) | undefined, onConfirm: undefined as (() => void) | undefined,
showCancel: false, showCancel: false,
confirmText: "OK",
}); });
const [autoAssigningReturn, setAutoAssigningReturn] = useState(false);
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP"; const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
const isPackageBooking = !!packageName;
const currentSchedule = const currentSchedule =
isRoundTrip && currentJourneyType === "inbound" isRoundTrip && currentJourneyType === "inbound"
? inboundSchedule ? inboundSchedule
@@ -306,8 +314,10 @@ export default function SeatsPage() {
return raw return raw
.map((c: any, idx: number) => ({ .map((c: any, idx: number) => ({
id: c.id || c.coachId || String(idx), 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}`, label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`,
type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""), 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 || "", typeName: c.coachTypeName || c.coachType || c.category || c.type || "",
coachTypeId: c.coachTypeId ?? c.typeId ?? null, coachTypeId: c.coachTypeId ?? c.typeId ?? null,
remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? 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); .sort((a: any, b: any) => a.sequence - b.sequence);
}, [trainCoachesData]); }, [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); 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 // 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 applyCoachTypeSwitch = (coach: any, matchedType: any) => {
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
const firstClass = matchedType.classes?.[0]; const firstClass = matchedType.classes?.[0];
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
const updatedSchedule = { const updatedSchedule = {
...(currentSchedule as any), ...(currentSchedule as any),
selectedCoachTypeId: matchedType.coachTypeId || matchedType.coachId, selectedCoachTypeId: newCoachTypeId,
selectedCoachTypeCode: matchedType.coachTypeCode, selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
selectedCoachTypeName: matchedType.coachTypeName, selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
selectedSeatClass: firstClass?.name || matchedType.coachTypeName, selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName, selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
// review/page.tsx's fare-breakdown request reads THIS field (not seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
// 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,
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
}; };
@@ -382,6 +406,11 @@ export default function SeatsPage() {
setInboundSchedule(updatedSchedule); setInboundSchedule(updatedSchedule);
} else if (isRoundTrip) { } else if (isRoundTrip) {
setOutboundSchedule(updatedSchedule); 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 { } else {
setSelectedSchedule(updatedSchedule); setSelectedSchedule(updatedSchedule);
} }
@@ -391,6 +420,19 @@ export default function SeatsPage() {
setSelectedCoach(null); setSelectedCoach(null);
setPendingCoachLabel(coach.label); setPendingCoachLabel(coach.label);
setShowCoachPreview(false); 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 // Same coach type as the one already loaded — no refetch needed, just bring this
@@ -401,12 +443,6 @@ export default function SeatsPage() {
setShowCoachPreview(false); 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) => { const handlePreviewCoachSelect = (coach: any) => {
if (coach.remainingSeats != null && coach.remainingSeats <= 0) { if (coach.remainingSeats != null && coach.remainingSeats <= 0) {
setModalState({ setModalState({
@@ -416,31 +452,71 @@ export default function SeatsPage() {
type: "warning", type: "warning",
onConfirm: undefined, onConfirm: undefined,
showCancel: false, showCancel: false,
confirmText: "OK",
}); });
return; return;
} }
const types = (currentSchedule as any)?.coachTypes || []; // Resolve the real CoachType UUID by cross-referencing the seatmap data,
const matchedType = types.find( // since the preview API (/seats/coaches) returns physical coach IDs, not CoachType UUIDs.
(ct: any) => const resolvedCoachTypeId = resolveCoachTypeIdFromSeatmap(coach);
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);
if (!matchedType || isSameType) { // Same coach type as currently loaded — just scroll to it.
// Same coach type — just bring this physical coach's seat map into view. // 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); focusCoachInPlace(coach);
return; return;
} }
// Different coach type — switch to it and load its seat map; per-seat fare // Different coach type — build matchedType from coachTypes array or synthesise.
// confirmation (if any) happens once the user picks an actual seat. const types = (currentSchedule as any)?.coachTypes || [];
applyCoachTypeSwitch(coach, matchedType); 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({ const holdMutation = useMutation({
@@ -693,7 +769,9 @@ export default function SeatsPage() {
const newSeat = validSeats?.find((s: any) => s.id === seatId); const newSeat = validSeats?.find((s: any) => s.id === seatId);
const newFare = newSeat ? getSeatFare(newSeat) : null; 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 referenceFare: number | null = null;
let referenceLabel = "the fare you originally selected"; let referenceLabel = "the fare you originally selected";
@@ -723,9 +801,10 @@ export default function SeatsPage() {
setModalState({ setModalState({
isOpen: true, isOpen: true,
title: "Fare Will Change", 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", type: "warning",
showCancel: true, showCancel: true,
confirmText: "Continue",
onConfirm: () => commitSeatAssignment(seatId), onConfirm: () => commitSeatAssignment(seatId),
}); });
return; return;
@@ -764,6 +843,152 @@ export default function SeatsPage() {
}; };
}); });
setPassengers(updatedPassengers); 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<string>();
const inboundSeatMap: Record<number, string> = {};
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) { } catch (error: any) {
setModalState({ setModalState({
isOpen: true, isOpen: true,
@@ -774,6 +999,7 @@ export default function SeatsPage() {
type: "error", type: "error",
onConfirm: undefined, onConfirm: undefined,
showCancel: false, showCancel: false,
confirmText: "OK",
}); });
return; return;
} }
@@ -818,6 +1044,7 @@ export default function SeatsPage() {
type: "error", type: "error",
onConfirm: undefined, onConfirm: undefined,
showCancel: false, showCancel: false,
confirmText: "OK",
}); });
return; return;
} }
@@ -841,6 +1068,7 @@ export default function SeatsPage() {
type: "warning", type: "warning",
onConfirm: undefined, onConfirm: undefined,
showCancel: false, showCancel: false,
confirmText: "OK",
}); });
return; return;
} }
@@ -1323,7 +1551,9 @@ export default function SeatsPage() {
const isCurrentType = const isCurrentType =
!!coachTypeId && !!coachTypeId &&
(coach.coachTypeId === coachTypeId || (coach.coachTypeId === coachTypeId ||
coach.coachId === coachTypeId ||
coach.type === (currentSchedule as any)?.selectedCoachTypeCode || coach.type === (currentSchedule as any)?.selectedCoachTypeCode ||
coach.coachTypeName === (currentSchedule as any)?.selectedCoachTypeName ||
coach.type === (currentSchedule as any)?.selectedCoachTypeName); coach.type === (currentSchedule as any)?.selectedCoachTypeName);
return ( return (
<div key={coach.id}> <div key={coach.id}>
@@ -1503,14 +1733,16 @@ export default function SeatsPage() {
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={!allSeatsAssigned || holdMutation.isPending} disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg" className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
> >
{holdMutation.isPending {autoAssigningReturn
? "Holding seats..." ? "Assigning return seats..."
: isRoundTrip && currentJourneyType === "outbound" : holdMutation.isPending
? "Continue to Return Seats" ? "Holding seats..."
: "Continue"} : isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
? "Continue to Return Seats"
: "Continue"}
</button> </button>
<button <button
onClick={handleAutoAssign} onClick={handleAutoAssign}
@@ -1526,13 +1758,13 @@ export default function SeatsPage() {
<> <>
<CustomModal <CustomModal
isOpen={modalState.isOpen} isOpen={modalState.isOpen}
onClose={() => setModalState({ ...modalState, isOpen: false })} onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
title={modalState.title} title={modalState.title}
message={modalState.message} message={modalState.message}
type={modalState.type} type={modalState.type}
onConfirm={modalState.onConfirm} onConfirm={modalState.onConfirm}
showCancel={modalState.showCancel} showCancel={modalState.showCancel}
confirmText={modalState.showCancel ? "Switch Coach" : "OK"} confirmText={modalState.confirmText}
/> />
{/* Train Coach Preview */} {/* Train Coach Preview */}
@@ -1561,7 +1793,7 @@ export default function SeatsPage() {
{/* Desktop: right-side panel, no backdrop — seat selection stays fully usable */} {/* Desktop: right-side panel, no backdrop — seat selection stays fully usable */}
<div <div
className="hidden lg:flex fixed inset-y-0 right-0 z-[120] w-[380px] bg-white dark:bg-gray-900 shadow-2xl border-l border-gray-200 dark:border-gray-700 flex-col" className="hidden lg:flex fixed inset-y-0 right-0 z-[90] w-[380px] bg-white dark:bg-gray-900 shadow-2xl border-l border-gray-200 dark:border-gray-700 flex-col"
style={{ animation: "drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)" }} style={{ animation: "drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)" }}
> >
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0"> <div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
@@ -1612,11 +1844,13 @@ export default function SeatsPage() {
</button> </button>
<div className="text-center"> <div className="text-center">
<h1 className="text-base font-bold text-gray-900 dark:text-white"> <h1 className="text-base font-bold text-gray-900 dark:text-white">
{isRoundTrip {isPackageBooking
? currentJourneyType === "outbound" ? `Select Seats for ${packageName}`
? packageName ? `Select Outbound Seats for ${packageName}` : "Select Outbound Seats" : isRoundTrip
: packageName ? `Select Return Seats for ${packageName}` : "Select Return Seats" ? currentJourneyType === "outbound"
: "Select Seats"} ? "Select Outbound Seats"
: "Select Return Seats"
: "Select Seats"}
</h1> </h1>
{!allSeatsAssigned && ( {!allSeatsAssigned && (
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">

View File

@@ -365,6 +365,7 @@ const PKG_CHILDREN_PER_ADULT = 5;
function PassengerCountModal({ function PassengerCountModal({
tier, tier,
minPriceMinor,
onClose, onClose,
onConfirm, onConfirm,
loading, loading,
@@ -373,6 +374,7 @@ function PassengerCountModal({
stations, stations,
}: { }: {
tier: PriceTier; tier: PriceTier;
minPriceMinor: number;
onClose: () => void; onClose: () => void;
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void; onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
loading: boolean; loading: boolean;
@@ -389,7 +391,7 @@ function PassengerCountModal({
const freeChildren = Math.min(childCount, adultCount); const freeChildren = Math.min(childCount, adultCount);
const paidChildren = Math.max(0, childCount - adultCount); const paidChildren = Math.max(0, childCount - adultCount);
// Only paid children need seats; free children share with an adult // 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 ( return (
<> <>
@@ -406,7 +408,7 @@ function PassengerCountModal({
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10"> <div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p> <p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p> <p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p> <p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
</div> </div>
<div className="px-6 py-5 space-y-4"> <div className="px-6 py-5 space-y-4">
@@ -442,7 +444,7 @@ function PassengerCountModal({
</div> </div>
)} )}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800"> <div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span> <span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''}</span>
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span> <span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
</div> </div>
@@ -557,6 +559,15 @@ export default function PackageDetailPage() {
selectedSeatClassName: ctx.seatClassName ?? "", selectedSeatClassName: ctx.seatClassName ?? "",
seatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "",
selectedCoachTypeId: ctx.coachTypeId ?? "", 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); const outboundSched = toSchedule(ctx.outboundSchedule);
@@ -648,6 +659,7 @@ export default function PackageDetailPage() {
{passengerModalOpen && representativeTier && ( {passengerModalOpen && representativeTier && (
<PassengerCountModal <PassengerCountModal
tier={representativeTier} tier={representativeTier}
minPriceMinor={selectedGroup?.minPrice ?? representativeTier.priceMinor}
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }} onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
onConfirm={handleBookNow} onConfirm={handleBookNow}
loading={bookingContextLoading} loading={bookingContextLoading}