@@ -66,6 +73,31 @@ const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) =>
BedCard.displayName = "BedCard";
+// A real berth ladder is a single fixed rail mounted at the end of the bay that a
+// passenger climbs to reach every level — not a separate rung floating between each
+// pair of beds. So this renders once per bay, right after the last berth card, with
+// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines.
+const LadderConnector = memo(() => (
+
+
+
+));
+
+LadderConnector.displayName = "LadderConnector";
+
const SeatButton = memo(
({
seat,
@@ -134,6 +166,9 @@ export default function SeatsPage() {
passengers,
setSeatHold,
setPassengers,
+ setSelectedSchedule,
+ setOutboundSchedule,
+ setInboundSchedule,
searchCriteria,
bookingId,
packageName,
@@ -146,6 +181,12 @@ export default function SeatsPage() {
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
const [selectedCoach, setSelectedCoach] = useState(null);
+ // Label of the coach the user picked from the Train Coach Preview, waiting to be
+ // focused once the (possibly newly-fetched) seat map data for its coach type is ready.
+ // Matched by label rather than id since the preview and seatmap endpoints are separate
+ // API calls and may not share the same coach id scheme.
+ const [pendingCoachLabel, setPendingCoachLabel] = useState(null);
+ const [showCoachPreview, setShowCoachPreview] = useState(false);
const [currentJourneyType, setCurrentJourneyType] = useState<
"outbound" | "inbound"
>("outbound");
@@ -154,6 +195,8 @@ export default function SeatsPage() {
title: "",
message: "",
type: "info" as "warning" | "error" | "success" | "info",
+ onConfirm: undefined as (() => void) | undefined,
+ showCancel: false,
});
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
@@ -168,6 +211,58 @@ export default function SeatsPage() {
? currentJourneyType === "inbound" ? "RETURN" : "OUTBOUND"
: "ONE_WAY";
+ // Baseline fare for each leg as it was when this page first loaded — i.e. whatever was
+ // picked on the results page ("starting from" price). Captured once and never
+ // overwritten, so a later coach-type switch (or just picking a pricier berth) can still
+ // be compared against what the user originally saw/selected.
+ const originalFaresRef = useRef<{ outbound: number | null; inbound: number | null; oneWay: number | null }>({
+ outbound: null,
+ inbound: null,
+ oneWay: null,
+ });
+ if (originalFaresRef.current.outbound === null && outboundSchedule?.baseFareAdult != null) {
+ originalFaresRef.current.outbound = outboundSchedule.baseFareAdult;
+ }
+ if (originalFaresRef.current.inbound === null && inboundSchedule?.baseFareAdult != null) {
+ originalFaresRef.current.inbound = inboundSchedule.baseFareAdult;
+ }
+ if (originalFaresRef.current.oneWay === null && selectedSchedule?.baseFareAdult != null) {
+ originalFaresRef.current.oneWay = selectedSchedule.baseFareAdult;
+ }
+ const originalFareForCurrentLeg = isRoundTrip
+ ? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound)
+ : originalFaresRef.current.oneWay;
+
+ // Child seat allocation rule: let A = adults, C = children (isChild = under 5).
+ // If C > A, only A - 1 children get their own seat and the rest share with an adult.
+ // If C <= A, no child gets a separate seat — all of them share with an adult.
+ // Adults always need their own seat.
+ const seatEligibility = useMemo(() => {
+ const adultIndices = passengers.map((_, i) => i).filter((i) => !isChild(passengers[i]));
+ const childIndices = passengers.map((_, i) => i).filter((i) => isChild(passengers[i]));
+ const adultCount = adultIndices.length;
+ const childCount = childIndices.length;
+ const eligibleChildCount = childCount > adultCount ? Math.max(adultCount - 1, 0) : 0;
+ const eligibleChildIndices = childIndices.slice(0, eligibleChildCount);
+ const eligibleSet = new Set([...adultIndices, ...eligibleChildIndices]);
+
+ // Children who don't get their own seat share with an adult (round-robin, for display).
+ const sharingWithAdult = new Map();
+ childIndices.slice(eligibleChildCount).forEach((childIdx, offset) => {
+ const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)];
+ if (adultIdx != null) {
+ sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`);
+ }
+ });
+
+ return { eligibleSet, sharingWithAdult };
+ }, [passengers]);
+
+ const seatEligibleIndices = useMemo(
+ () => passengers.map((_, i) => i).filter((i) => seatEligibility.eligibleSet.has(i)),
+ [passengers, seatEligibility],
+ );
+
const {
data: seatMapData,
isLoading,
@@ -189,6 +284,161 @@ export default function SeatsPage() {
enabled: !!currentSchedule?.id && !!coachTypeId,
});
+ // Whole-train coach layout for the "Preview Train Coach" panel — a separate, lazily
+ // fetched list of every coach on this schedule (not just the currently selected coach
+ // type), so users can see the full train arrangement and spot the dining coach.
+ const { data: trainCoachesData, isLoading: loadingTrainCoaches } = useQuery({
+ queryKey: ["trainCoaches", currentSchedule?.id],
+ queryFn: async () => {
+ const response = await apiClient.get(`/seats/coaches/${currentSchedule?.id}`);
+ return (response as any)?.data || response;
+ },
+ enabled: showCoachPreview && !!currentSchedule?.id,
+ });
+
+ const trainCoachList = useMemo(() => {
+ const raw = (trainCoachesData as any)?.coaches || trainCoachesData || [];
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map((c: any, idx: number) => ({
+ id: c.id || c.coachId || String(idx),
+ label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`,
+ type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""),
+ typeName: c.coachTypeName || c.coachType || c.category || c.type || "",
+ coachTypeId: c.coachTypeId ?? c.typeId ?? null,
+ remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? null,
+ sequence: c.sequence ?? c.order ?? idx,
+ }))
+ .sort((a: any, b: any) => a.sequence - b.sequence);
+ }, [trainCoachesData]);
+
+ 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
+ // when this schedule was first selected on the results page (see booking-store.ts).
+ // Returns null if the type can't be matched or has no priced classes.
+ const getCoachTypeFare = (coachTypeId: string | null | undefined): number | null => {
+ if (!coachTypeId) return null;
+ const types = (currentSchedule as any)?.coachTypes || [];
+ const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId);
+ const fares = (match?.classes || []).map((c: any) => c.baseFareMinor).filter((f: number) => f > 0);
+ return fares.length ? Math.min(...fares) : null;
+ };
+
+ // The current coach type's priced classes (e.g. bed coaches price Upper/Middle/Lower
+ // differently) — used to look up the real fare for a specific seat, not just the
+ // coach type's cheapest class.
+ const currentCoachTypeClasses = useMemo(() => {
+ const types = (currentSchedule as any)?.coachTypes || [];
+ const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId);
+ return match?.classes || [];
+ }, [currentSchedule, coachTypeId]);
+
+ // A specific seat's actual fare: bed positions (Upper/Middle/Lower) are priced as
+ // separate classes, so this can differ from the coach type's flat minimum fare.
+ const getSeatFare = useCallback(
+ (seat: any): number | null => {
+ if (!currentCoachTypeClasses.length) return null;
+ if (seat?.bedPosition) {
+ const match = currentCoachTypeClasses.find((c: any) =>
+ c.name?.toLowerCase().includes(seat.bedPosition),
+ );
+ if (match) return match.baseFareMinor;
+ }
+ const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
+ return (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
+ },
+ [currentCoachTypeClasses],
+ );
+
+ // Switches the schedule to a different coach type (updates fare/class fields in the
+ // booking store, which the seatmap query picks up automatically since it's keyed on
+ // coachTypeId) and clears any seat picks made under the old coach type, since they no
+ // longer correspond to real seats. Queues the clicked coach's label to be focused once
+ // the new seat map data has finished loading.
+ const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
+ const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
+ const firstClass = matchedType.classes?.[0];
+ 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,
+ baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
+ baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
+ };
+
+ if (isRoundTrip && currentJourneyType === "inbound") {
+ setInboundSchedule(updatedSchedule);
+ } else if (isRoundTrip) {
+ setOutboundSchedule(updatedSchedule);
+ } else {
+ setSelectedSchedule(updatedSchedule);
+ }
+
+ setPassengerSeatMap({});
+ setActivePassengerIndex(seatEligibleIndices[0] ?? 0);
+ setSelectedCoach(null);
+ setPendingCoachLabel(coach.label);
+ setShowCoachPreview(false);
+ };
+
+ // Same coach type as the one already loaded — no refetch needed, just bring this
+ // specific physical coach into view once we can match it against the (already
+ // available) seat map data.
+ const focusCoachInPlace = (coach: any) => {
+ setPendingCoachLabel(coach.label);
+ 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({
+ isOpen: true,
+ title: "Coach Full",
+ message: `${coach.label} has no remaining seats. Please choose a different coach.`,
+ type: "warning",
+ onConfirm: undefined,
+ showCancel: false,
+ });
+ 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);
+
+ if (!matchedType || isSameType) {
+ // Same coach type — just bring this physical coach's seat map into view.
+ 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);
+ };
+
const holdMutation = useMutation({
mutationFn: async (seatIds: string[]) => {
const passengersForHold = passengers
@@ -267,6 +517,20 @@ export default function SeatsPage() {
return coachesWithSeats;
}, [coaches]);
+ // Resolves a coach picked from the Train Coach Preview once its seat map data is
+ // actually ready — matching by label (not id) since the preview list and this seat
+ // map come from separate API calls. Waits out any in-flight refetch triggered by a
+ // coach-type switch before trying to match, so it doesn't act on stale/old-type data.
+ useEffect(() => {
+ if (!pendingCoachLabel) return;
+ if (isLoading) return;
+ if (!filteredCoaches.length) return;
+ const match = filteredCoaches.find(
+ (c: any) => (c.label || c.name || c.coachNumber || "") === pendingCoachLabel,
+ );
+ setSelectedCoach((match || filteredCoaches[0])?.id || null);
+ setPendingCoachLabel(null);
+ }, [pendingCoachLabel, filteredCoaches, isLoading]);
const selectedCoachData = useMemo(
() => filteredCoaches.find((c: any) => c.id === selectedCoach),
@@ -346,13 +610,16 @@ export default function SeatsPage() {
);
// The furthest passenger a user is allowed to jump to — cannot skip ahead of the
- // first passenger who still needs a seat.
+ // first seat-eligible passenger who still needs a seat. Passengers who share a seat
+ // with an adult (see seatEligibility) never need their own pick.
const firstUnassignedIndex = useMemo(
- () => passengers.findIndex((_, i) => !passengerSeatMap[i]),
- [passengers, passengerSeatMap],
+ () => seatEligibleIndices.find((i) => !passengerSeatMap[i]) ?? -1,
+ [seatEligibleIndices, passengerSeatMap],
);
const maxSelectableIndex =
- firstUnassignedIndex === -1 ? passengers.length - 1 : firstUnassignedIndex;
+ firstUnassignedIndex === -1
+ ? seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0
+ : firstUnassignedIndex;
const isSeatSelected = useCallback(
(seatId: string) => passengerSeatMap[activePassengerIndex] === seatId,
@@ -369,20 +636,18 @@ export default function SeatsPage() {
const handleSelectPassenger = useCallback(
(index: number) => {
+ if (!seatEligibility.eligibleSet.has(index)) return; // shares a seat with an adult — no pick needed
if (index > maxSelectableIndex) return; // no skipping ahead of unassigned passengers
setActivePassengerIndex(index);
},
- [maxSelectableIndex],
+ [maxSelectableIndex, seatEligibility],
);
- const handleSeatClick = useCallback(
+ // Actually applies a seat pick/deselect for the active passenger — split out from
+ // handleSeatClick so a price-difference confirmation can defer this until the user
+ // confirms, instead of assigning immediately.
+ const commitSeatAssignment = useCallback(
(seatId: string) => {
- // Seat already claimed by a different passenger — never allow duplicate assignment
- const takenByOther = Object.entries(passengerSeatMap).some(
- ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
- );
- if (takenByOther) return;
-
const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
const next = { ...passengerSeatMap };
if (isDeselecting) {
@@ -394,26 +659,95 @@ export default function SeatsPage() {
if (!isDeselecting) {
// Move on to the next passenger who still needs a seat — one passenger at a time
- const nextUnassigned = passengers.findIndex(
- (_, i) => i !== activePassengerIndex && !next[i],
+ const nextUnassigned = seatEligibleIndices.find(
+ (i) => i !== activePassengerIndex && !next[i],
);
- if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned);
+ if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned);
}
},
- [passengerSeatMap, activePassengerIndex, passengers],
+ [passengerSeatMap, activePassengerIndex, seatEligibleIndices],
+ );
+
+ const handleSeatClick = useCallback(
+ (seatId: string) => {
+ // Seat already claimed by a different passenger — never allow duplicate assignment
+ const takenByOther = Object.entries(passengerSeatMap).some(
+ ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
+ );
+ if (takenByOther) return;
+
+ const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
+ if (isDeselecting) {
+ commitSeatAssignment(seatId);
+ return;
+ }
+
+ // Bed coaches price Upper/Middle/Lower differently, so picking a seat whose fare
+ // differs from what the user originally selected (e.g. after switching coach type
+ // via the preview, or just picking a pricier berth) — or from another passenger's
+ // already-selected seat — needs a heads-up before it's applied.
+ const newSeat = validSeats?.find((s: any) => s.id === seatId);
+ const newFare = newSeat ? getSeatFare(newSeat) : null;
+
+ if (newFare != null) {
+ let referenceFare: number | null = null;
+ let referenceLabel = "the fare you originally selected";
+
+ if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
+ referenceFare = originalFareForCurrentLeg;
+ } else {
+ const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
+ if (Number(idx) === activePassengerIndex) return false;
+ const otherSeat = validSeats?.find((s: any) => s.id === sid);
+ const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
+ return otherFare != null && otherFare !== newFare;
+ });
+
+ if (differingEntry) {
+ const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
+ referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
+ referenceLabel = "another already-selected seat";
+ }
+ }
+
+ if (referenceFare != null) {
+ const seatLabel = buildSeatLabel(newSeat);
+ const positionLabel = newSeat?.bedPosition
+ ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
+ : "This seat";
+
+ setModalState({
+ isOpen: true,
+ title: "Fare Will Change",
+ message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`,
+ type: "warning",
+ showCancel: true,
+ onConfirm: () => commitSeatAssignment(seatId),
+ });
+ return;
+ }
+ }
+
+ commitSeatAssignment(seatId);
+ },
+ [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment],
);
const allSeatsAssigned =
- passengers.length > 0 &&
- passengers.every((_, i) => !!passengerSeatMap[i]);
+ seatEligibleIndices.length > 0 &&
+ seatEligibleIndices.every((i) => !!passengerSeatMap[i]);
const handleContinue = async () => {
if (!allSeatsAssigned) return;
+ // Indexed by original passenger position — holes for passengers who share a seat
+ // with an adult (no seat picked, and none required).
const seatIds = passengers.map((_, i) => passengerSeatMap[i]);
+ // Only real, distinct seat ids go to the hold API.
+ const seatIdsForHold = seatEligibleIndices.map((i) => passengerSeatMap[i]);
if (isRoundTrip && currentJourneyType === "outbound") {
try {
- await holdMutation.mutateAsync(seatIds);
+ await holdMutation.mutateAsync(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
return {
@@ -421,6 +755,8 @@ export default function SeatsPage() {
outboundSeatId: seatIds[i],
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
outboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
+ outboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
+ outboundBedPosition: seatData?.bedPosition || undefined,
};
});
setPassengers(updatedPassengers);
@@ -432,18 +768,20 @@ export default function SeatsPage() {
error?.response?.data?.message ||
"Failed to hold seats. Please try again.",
type: "error",
+ onConfirm: undefined,
+ showCancel: false,
});
return;
}
setCurrentJourneyType("inbound");
setPassengerSeatMap({});
- setActivePassengerIndex(0);
+ setActivePassengerIndex(seatEligibleIndices[0] ?? 0);
setSelectedCoach(null);
return;
}
try {
- await holdMutation.mutateAsync(seatIds);
+ await holdMutation.mutateAsync(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
if (isRoundTrip && currentJourneyType === "inbound") {
@@ -452,6 +790,8 @@ export default function SeatsPage() {
inboundSeatId: seatIds[i],
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
inboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
+ inboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
+ inboundBedPosition: seatData?.bedPosition || undefined,
};
}
return {
@@ -459,6 +799,8 @@ export default function SeatsPage() {
seatId: seatIds[i],
seatNumber: seatData ? buildSeatLabel(seatData) : '',
coachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
+ seatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
+ bedPosition: seatData?.bedPosition || undefined,
};
});
setPassengers(updatedPassengers);
@@ -470,6 +812,8 @@ export default function SeatsPage() {
error?.response?.data?.message ||
"Failed to hold seats. Please try again.",
type: "error",
+ onConfirm: undefined,
+ showCancel: false,
});
return;
}
@@ -477,9 +821,7 @@ export default function SeatsPage() {
};
const handleAutoAssign = () => {
- const unassignedIndices = passengers
- .map((_, i) => i)
- .filter((i) => !passengerSeatMap[i]);
+ const unassignedIndices = seatEligibleIndices.filter((i) => !passengerSeatMap[i]);
if (unassignedIndices.length === 0) return;
@@ -493,6 +835,8 @@ export default function SeatsPage() {
title: "Not Enough Seats",
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`,
type: "warning",
+ onConfirm: undefined,
+ showCancel: false,
});
return;
}
@@ -502,7 +846,7 @@ export default function SeatsPage() {
next[passengerIndex] = availableSeats[offset].id;
});
setPassengerSeatMap(next);
- setActivePassengerIndex(passengers.length - 1);
+ setActivePassengerIndex(seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0);
};
const handleBackToPassengers = () => {
@@ -536,6 +880,15 @@ export default function SeatsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
+ // A passenger who shares a seat with an adult should never be "active" for seat
+ // picking — snap back to the first seat-eligible passenger if that ever happens
+ // (e.g. right after passengers/counts change).
+ useEffect(() => {
+ if (!seatEligibility.eligibleSet.has(activePassengerIndex) && seatEligibleIndices.length > 0) {
+ setActivePassengerIndex(seatEligibleIndices[0]);
+ }
+ }, [seatEligibility, seatEligibleIndices, activePassengerIndex]);
+
const parseSeatArrangement = (
arrangement: string | null,
seatClasses?: string[],
@@ -565,13 +918,6 @@ export default function SeatsPage() {
return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
};
- const getBedLabel = (bedPosition: string | null): string => {
- if (bedPosition === "upper") return "U";
- if (bedPosition === "middle") return "M";
- if (bedPosition === "lower") return "L";
- return "";
- };
-
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const arrangement = parseSeatArrangement(
coach.seatArrangement,
@@ -588,6 +934,54 @@ export default function SeatsPage() {
? selectedCoachData.seatClass
: selectedCoachData?.seatClass?.name || "";
+ // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with
+ // the single ladder that actually serves the whole bay shown once at the end.
+ const renderBerthBay = (beds: any[], keyPrefix: string) => (
+
+ );
+
+ // Two-side compartment: the left bay and right bay each get their own row (berths
+ // still laid out horizontally within a row), stacked one above the other and split
+ // by a dashed aisle divider — instead of squeezing both sides into a single row.
+ const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => (
+
);
@@ -1111,7 +1266,7 @@ export default function SeatsPage() {
);
}
- const assignedCount = passengers.filter((_, i) => !!passengerSeatMap[i]).length;
+ const assignedCount = seatEligibleIndices.filter((i) => !!passengerSeatMap[i]).length;
const activePassenger = passengers[activePassengerIndex];
const isBedCoach =
selectedCoachData?.isBedCoach === true ||
@@ -1119,6 +1274,114 @@ export default function SeatsPage() {
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed");
+ // Train coach preview content — shared between the desktop side panel and the
+ // mobile full-screen modal. Shows every coach on this schedule in order, so users
+ // can see where their selected coach type sits relative to the rest of the train
+ // and spot the dining coach at a glance.
+ const TrainCoachPreviewContent = () => (
+ <>
+