mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: (reschedule) share the booking flow's seat map and add a sticky change summary
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
@@ -11,6 +11,7 @@ import StationDropdown, {
|
||||
pushRecentStation,
|
||||
readRecentStationIds,
|
||||
} from "@/components/StationDropdown";
|
||||
import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
|
||||
import { formatTime } from "@/utils/format";
|
||||
import { Station } from "@/types";
|
||||
|
||||
@@ -70,6 +71,13 @@ type Quote = {
|
||||
|
||||
const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`;
|
||||
|
||||
/** "Sat 29 Aug, 21:00" — the summary's departure line. Null when the value isn't a real date. */
|
||||
const formatDepartureDay = (value: string | Date | undefined | null): string | null => {
|
||||
if (!value) return null;
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
return isNaN(d.getTime()) ? null : format(d, "EEE dd MMM, HH:mm");
|
||||
};
|
||||
|
||||
function ReschedulePageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -81,7 +89,12 @@ function ReschedulePageContent() {
|
||||
const [destinationId, setDestinationId] = useState("");
|
||||
const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null);
|
||||
const [schedule, setSchedule] = useState<any | null>(null);
|
||||
const [seatIds, setSeatIds] = useState<string[]>([]);
|
||||
// Seat per passenger, keyed by index into leg.passengerNames. The API pairs newSeatIds[i]
|
||||
// with the i-th BookingSeat in that same order, so an explicit map is the only way the
|
||||
// right passenger keeps the right fare (a free child must not inherit an adult's seat).
|
||||
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
||||
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<{ status: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dateNotice, setDateNotice] = useState<string | null>(null);
|
||||
@@ -91,6 +104,12 @@ function ReschedulePageContent() {
|
||||
const saveRecent = (id: string) =>
|
||||
setRecentStationIds((prev) => pushRecentStation(id, prev));
|
||||
|
||||
const resetSeats = () => {
|
||||
setPassengerSeatMap({});
|
||||
setActivePassengerIndex(0);
|
||||
setSelectedCoach(null);
|
||||
};
|
||||
|
||||
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
|
||||
queryKey: ["reschedule-options", ref],
|
||||
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`),
|
||||
@@ -110,7 +129,7 @@ function ReschedulePageContent() {
|
||||
setOriginId(leg.originStationId ?? "");
|
||||
setDestinationId(leg.destinationStationId ?? "");
|
||||
setSchedule(null);
|
||||
setSeatIds([]);
|
||||
resetSeats();
|
||||
setSearched(null);
|
||||
}, [leg?.leg, leg?.scheduleId]);
|
||||
|
||||
@@ -163,7 +182,7 @@ function ReschedulePageContent() {
|
||||
setDate(undefined);
|
||||
setSearched(null);
|
||||
setSchedule(null);
|
||||
setSeatIds([]);
|
||||
resetSeats();
|
||||
setDateNotice("No trains run this route on that date — please pick another.");
|
||||
}
|
||||
}, [date, disabledDates]);
|
||||
@@ -175,7 +194,7 @@ function ReschedulePageContent() {
|
||||
setDate(undefined);
|
||||
setSearched(null);
|
||||
setSchedule(null);
|
||||
setSeatIds([]);
|
||||
resetSeats();
|
||||
}
|
||||
}, [noRouteForPair, date]);
|
||||
|
||||
@@ -208,8 +227,62 @@ function ReschedulePageContent() {
|
||||
enabled: !!scheduleId && !!leg,
|
||||
});
|
||||
|
||||
const quoteBody = leg && scheduleId && seatIds.length === leg.seatCount
|
||||
? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds }
|
||||
const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]);
|
||||
|
||||
// Expand the first coach automatically — with one coach of the booked class on most trains,
|
||||
// making the user open it before any seat is visible is a click for nothing. Fires once per
|
||||
// schedule: keying it on `selectedCoach` instead would re-open the coach the moment the user
|
||||
// collapsed it, since collapsing sets selectedCoach back to null.
|
||||
const autoExpandedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!scheduleId || coaches.length === 0) return;
|
||||
if (autoExpandedFor.current === scheduleId) return;
|
||||
autoExpandedFor.current = scheduleId;
|
||||
setSelectedCoach(coaches[0].id);
|
||||
}, [scheduleId, coaches]);
|
||||
|
||||
// newSeatIds must line up with the leg's BookingSeats, which the API orders by passenger
|
||||
// name — the same order `passengerNames` arrives in. Indexing by passenger builds that
|
||||
// order by construction, so there is nothing for a click sequence to get wrong.
|
||||
const orderedSeatIds = useMemo(
|
||||
() => Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => passengerSeatMap[i]).filter(Boolean) as string[],
|
||||
[passengerSeatMap, leg?.seatCount],
|
||||
);
|
||||
const allSeatsChosen = !!leg && orderedSeatIds.length === leg.seatCount;
|
||||
|
||||
const isSeatSelected = (seatId: string) => passengerSeatMap[activePassengerIndex] === seatId;
|
||||
const isSeatAssignedToOther = (seatId: string) =>
|
||||
Object.entries(passengerSeatMap).some(
|
||||
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
|
||||
);
|
||||
|
||||
const handleSeatToggle = (seatId: string) => {
|
||||
if (isSeatAssignedToOther(seatId)) return;
|
||||
setPassengerSeatMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[activePassengerIndex] === seatId) {
|
||||
delete next[activePassengerIndex];
|
||||
return next;
|
||||
}
|
||||
next[activePassengerIndex] = seatId;
|
||||
// Move to the next passenger still without a seat so a multi-passenger leg can be
|
||||
// filled by clicking straight down the coach.
|
||||
const total = leg?.seatCount ?? 1;
|
||||
const nextUnassigned = Array.from({ length: total }, (_, i) => i).find((i) => !next[i]);
|
||||
if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// No skipping ahead of a passenger who still needs a seat — same rule as /booking/seats.
|
||||
const firstUnassignedIndex = Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => i).find(
|
||||
(i) => !passengerSeatMap[i],
|
||||
);
|
||||
const maxSelectableIndex =
|
||||
firstUnassignedIndex === undefined ? (leg?.seatCount ?? 1) - 1 : firstUnassignedIndex;
|
||||
|
||||
const quoteBody = leg && scheduleId && allSeatsChosen
|
||||
? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: orderedSeatIds }
|
||||
: null;
|
||||
const { data: quote, isFetching: quoting } = useQuery<Quote>({
|
||||
queryKey: ["reschedule-quote", ref, quoteBody],
|
||||
@@ -224,7 +297,7 @@ function ReschedulePageContent() {
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
journeyDirection,
|
||||
passengers: seatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })),
|
||||
passengers: orderedSeatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })),
|
||||
});
|
||||
return apiClient.post<any>(`/bookings/${ref}/reschedule`, { ...quoteBody, holdId: hold.holdId || hold.id });
|
||||
},
|
||||
@@ -235,14 +308,6 @@ function ReschedulePageContent() {
|
||||
onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not reschedule"),
|
||||
});
|
||||
|
||||
const toggleSeat = (id: string) => {
|
||||
setSeatIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((s) => s !== id);
|
||||
if (prev.length >= (leg?.seatCount ?? 1)) return [...prev.slice(1), id];
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
|
||||
|
||||
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
|
||||
@@ -283,11 +348,133 @@ function ReschedulePageContent() {
|
||||
}
|
||||
|
||||
const routeLocked = !leg.policy?.routeChangeAllowed;
|
||||
|
||||
// Short label/value pairs rather than prose — the rules are scanned, not read.
|
||||
const fareRules = leg.policy
|
||||
? [
|
||||
{
|
||||
label: "Change fee",
|
||||
value:
|
||||
leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0
|
||||
? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})`
|
||||
: "Free",
|
||||
},
|
||||
{ label: "Route change", value: leg.policy.routeChangeAllowed ? "Allowed" : "Not permitted" },
|
||||
{
|
||||
label: "Same-day change",
|
||||
value: !leg.policy.sameDayAllowed
|
||||
? "Not permitted"
|
||||
: leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0
|
||||
? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})`
|
||||
: "Free",
|
||||
},
|
||||
{ label: "Changes close", value: `${leg.policy.cutoffMinutes} min before departure` },
|
||||
{ label: "Higher new fare", value: "Payable" },
|
||||
{ label: "Lower new fare", value: "Not refunded" },
|
||||
]
|
||||
: [];
|
||||
|
||||
const canSearch =
|
||||
!!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair;
|
||||
|
||||
// Mirrors the booking flow's FareSidebar: a sticky money card that is present from the
|
||||
// start and fills in as choices are made, rather than a total that appears at the end.
|
||||
// Rendered twice — inline under the content on mobile, sticky beside it on desktop.
|
||||
const ChangeSummary = () => (
|
||||
<div className="card space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
|
||||
Change summary
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Currently</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatDepartureDay(leg.departureAt)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{stationName(leg.originStationId)} → {stationName(leg.destinationStationId)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedule && (
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Changing to</div>
|
||||
{/* Same date+time line as "Currently" above, so the two are read side by side. */}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatDepartureDay(schedule.departureAt) ?? formatTime(schedule.departureAt)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{schedule.trainName || schedule.trainNumber} · arrives {formatTime(schedule.arrivalAt)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{stationName(originId)} → {stationName(destinationId)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-1">
|
||||
{leg.passengerNames.map((name, i) => {
|
||||
const seatId = passengerSeatMap[i];
|
||||
const seat = seatId
|
||||
? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId)
|
||||
: null;
|
||||
return (
|
||||
<Shell>
|
||||
<div key={`sum-${name}-${i}`} className="flex justify-between text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[60%]">{name}</span>
|
||||
<span className={seat ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-400"}>
|
||||
{seat ? `Seat ${buildSeatLabel(seat)}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!quoteBody ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
Pick a new train and a seat for {leg.seatCount > 1 ? "every passenger" : "the passenger"} to see what this change costs.
|
||||
</p>
|
||||
) : quoting ? (
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : quote ? (
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-2 text-sm">
|
||||
<Row label="Original fare" value={etb(quote.oldFareMinor)} />
|
||||
<Row label="New fare" value={etb(quote.newFareMinor)} />
|
||||
<Row
|
||||
label={quote.fareDifferenceMinor >= 0 ? "Fare difference" : "Fare difference (not refunded)"}
|
||||
value={etb(Math.max(0, quote.fareDifferenceMinor))}
|
||||
/>
|
||||
<Row label={`Change fee${quote.isSameDay ? " (same-day)" : ""}`} value={etb(quote.feeMinor)} />
|
||||
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total due now</span>
|
||||
<span className="text-xl font-bold text-primary">{etb(quote.amountDueMinor)}</span>
|
||||
</div>
|
||||
{quote.blockers.length > 0 && (
|
||||
<div className="text-red-600 flex gap-2 text-xs">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-red-600 text-xs">{error}</div>}
|
||||
<button
|
||||
className="btn-primary w-full mt-1"
|
||||
disabled={!quote.allowed || confirm.isPending}
|
||||
onClick={() => { setError(null); confirm.mutate(); }}
|
||||
>
|
||||
{confirm.isPending
|
||||
? "Processing..."
|
||||
: quote.amountDueMinor > 0
|
||||
? `Continue to payment · ${etb(quote.amountDueMinor)}`
|
||||
: "Confirm reschedule"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell wide>
|
||||
<button onClick={() => router.push(`/booking/detail?ref=${ref}`)} className="flex items-center gap-1 text-sm text-gray-500 mb-4">
|
||||
<ChevronLeft className="w-4 h-4" /> Back to booking
|
||||
</button>
|
||||
@@ -306,12 +493,24 @@ function ReschedulePageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leg.policy && (
|
||||
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 text-sm mb-6 space-y-1">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Your fare rules</div>
|
||||
<div>Change fee: {leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` : "Free"}. A higher new fare is payable; a lower one is not refunded.</div>
|
||||
<div>Route change: {leg.policy.routeChangeAllowed ? "allowed" : "not permitted"}. Same-day change: {leg.policy.sameDayAllowed ? (leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` : "free") : "not permitted"}.</div>
|
||||
<div>Changes close {leg.policy.cutoffMinutes} minutes before departure.</div>
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||
{/* Left column — the choices */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
|
||||
{fareRules.length > 0 && (
|
||||
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||
<div className="font-semibold text-sm text-gray-900 dark:text-white mb-2.5">Your fare rules</div>
|
||||
<ul className="grid sm:grid-cols-2 gap-x-6 gap-y-2">
|
||||
{fareRules.map((rule) => (
|
||||
<li key={rule.label} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
|
||||
<span className="text-sm leading-snug text-gray-600 dark:text-gray-400">
|
||||
{rule.label}:{" "}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{rule.value}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -338,7 +537,7 @@ function ReschedulePageContent() {
|
||||
if (s.id) saveRecent(s.id);
|
||||
setDateNotice(null);
|
||||
setSchedule(null);
|
||||
setSeatIds([]);
|
||||
resetSeats();
|
||||
setSearched(null);
|
||||
}}
|
||||
/>
|
||||
@@ -354,7 +553,7 @@ function ReschedulePageContent() {
|
||||
if (s.id) saveRecent(s.id);
|
||||
setDateNotice(null);
|
||||
setSchedule(null);
|
||||
setSeatIds([]);
|
||||
resetSeats();
|
||||
setSearched(null);
|
||||
}}
|
||||
/>
|
||||
@@ -370,7 +569,7 @@ function ReschedulePageContent() {
|
||||
disabled={noRouteForPair}
|
||||
placeholder="New date"
|
||||
/>
|
||||
<button className="btn-primary" disabled={!canSearch || searching} onClick={() => { setSchedule(null); setSeatIds([]); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
|
||||
<button className="btn-primary" disabled={!canSearch || searching} onClick={() => { setSchedule(null); resetSeats(); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
|
||||
{searching ? "Searching..." : "Find trains"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -396,7 +595,7 @@ function ReschedulePageContent() {
|
||||
const id = s.scheduleId || s.id;
|
||||
const selected = scheduleId === id;
|
||||
return (
|
||||
<button key={id} disabled={s.hasAvailability === false} onClick={() => { setSchedule(s); setSeatIds([]); }}
|
||||
<button key={id} disabled={s.hasAvailability === false} onClick={() => { setSchedule(s); resetSeats(); }}
|
||||
className={`w-full text-left rounded-xl border p-4 flex items-center justify-between ${selected ? "border-primary bg-primary/5" : "border-gray-200 dark:border-gray-700"} disabled:opacity-50`}>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{s.trainName || s.trainNumber}</div>
|
||||
@@ -412,53 +611,106 @@ function ReschedulePageContent() {
|
||||
{/* Step 3: seats (same class as booked) */}
|
||||
{scheduleId && (
|
||||
<div className="mb-6">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white mb-2">Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({seatIds.length}/{leg.seatCount})</h2>
|
||||
{loadingSeats && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
{(seatMap?.coaches ?? []).map((coach: any) => (
|
||||
<div key={coach.id} className="mb-3">
|
||||
<div className="text-xs text-gray-500 mb-1">{coach.name} · {coach.coachTypeName}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(coach.seats ?? []).map((seat: any) => {
|
||||
const picked = seatIds.includes(seat.id);
|
||||
const free = seat.status === "AVAILABLE";
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({orderedSeatIds.length}/{leg.seatCount})
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{allSeatsChosen
|
||||
? "Every passenger has a seat."
|
||||
: `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`}
|
||||
</p>
|
||||
|
||||
{/* Who gets which seat. The API pairs seats to passengers by position, so this
|
||||
mapping is the payload — not a display convenience. */}
|
||||
<div className="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{leg.passengerNames.map((name, i) => {
|
||||
const assignedSeatId = passengerSeatMap[i];
|
||||
const assignedSeat = assignedSeatId
|
||||
? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === assignedSeatId)
|
||||
: null;
|
||||
const isActive = i === activePassengerIndex;
|
||||
const isClickable = i <= maxSelectableIndex;
|
||||
return (
|
||||
<button key={seat.id} disabled={!free} onClick={() => toggleSeat(seat.id)} title={`${seat.seatNumber} ${seat.bedPosition ?? ""} ${seat.status}`}
|
||||
className={`w-11 h-9 rounded text-xs border ${picked ? "bg-primary text-white border-primary" : free ? "bg-white dark:bg-gray-800 border-gray-300" : "bg-gray-200 dark:bg-gray-700 text-gray-400 border-transparent"}`}>
|
||||
{seat.seatNumber}{seat.bedPosition ? seat.bedPosition[0].toUpperCase() : ""}
|
||||
<button
|
||||
key={`${name}-${i}`}
|
||||
type="button"
|
||||
onClick={() => isClickable && setActivePassengerIndex(i)}
|
||||
disabled={!isClickable}
|
||||
className={`w-full flex items-center justify-between py-2 px-3 text-left transition-all ${
|
||||
isActive ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10" : ""
|
||||
} ${
|
||||
isClickable
|
||||
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||
assignedSeat
|
||||
? "bg-[rgb(20,113,76)] text-white"
|
||||
: isActive
|
||||
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[160px]">
|
||||
{name}
|
||||
</span>
|
||||
{isActive && !assignedSeat && (
|
||||
<span className="text-[10px] font-semibold text-[rgb(20,113,76)] uppercase tracking-wide">
|
||||
Now selecting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-semibold flex-shrink-0 ${
|
||||
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{assignedSeat ? `Seat ${buildSeatLabel(assignedSeat)}` : "Not Assigned"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{seatMap && (seatMap.coaches ?? []).length === 0 && <p className="text-sm text-gray-500">No coach of your class on this train.</p>}
|
||||
|
||||
{loadingSeats ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
) : (
|
||||
<SeatMap
|
||||
coaches={coaches}
|
||||
selectedCoachId={selectedCoach}
|
||||
onSelectCoach={setSelectedCoach}
|
||||
isSeatSelected={isSeatSelected}
|
||||
isSeatAssignedToOther={isSeatAssignedToOther}
|
||||
onSeatToggle={handleSeatToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: quote + confirm */}
|
||||
{quoteBody && (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-2 text-sm">
|
||||
{quoting && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
{quote && (
|
||||
<>
|
||||
<Row label="Original fare" value={etb(quote.oldFareMinor)} />
|
||||
<Row label="New fare" value={etb(quote.newFareMinor)} />
|
||||
<Row label={quote.fareDifferenceMinor >= 0 ? "Fare difference" : "Fare difference (not refunded)"} value={etb(Math.max(0, quote.fareDifferenceMinor))} />
|
||||
<Row label={`Change fee${quote.isSameDay ? " (same-day)" : ""}`} value={etb(quote.feeMinor)} />
|
||||
<div className="flex justify-between font-bold text-base pt-2 border-t border-gray-200 dark:border-gray-700"><span>Total due now</span><span>{etb(quote.amountDueMinor)}</span></div>
|
||||
{quote.blockers.length > 0 && (
|
||||
<div className="text-red-600 flex gap-2"><AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /><div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div></div>
|
||||
)}
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
<button className="btn-primary w-full mt-2" disabled={!quote.allowed || confirm.isPending} onClick={() => { setError(null); confirm.mutate(); }}>
|
||||
{confirm.isPending ? "Processing..." : quote.amountDueMinor > 0 ? `Continue to payment · ${etb(quote.amountDueMinor)}` : "Confirm reschedule"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>{/* end left card */}
|
||||
|
||||
{/* Mobile: the summary sits under the choices instead of beside them */}
|
||||
<div className="lg:hidden">
|
||||
<ChangeSummary />
|
||||
</div>
|
||||
</div>{/* end left column */}
|
||||
|
||||
{/* Right column — sticky change summary (desktop only) */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-6">
|
||||
<ChangeSummary />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -467,11 +719,19 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex justify-between text-gray-700 dark:text-gray-300"><span>{label}</span><span>{value}</span></div>;
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
// `wide` switches to the booking flow's two-column width and hands card styling to the
|
||||
// columns themselves; the narrow single-card form still carries the loading/error states.
|
||||
function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">{children}</div>
|
||||
{wide ? (
|
||||
<div className="max-w-6xl mx-auto">{children}</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,157 +6,18 @@ import { useRouter } from "next/navigation";
|
||||
import { useBookingStore } from "@/lib/booking-store";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
|
||||
import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react";
|
||||
|
||||
import CustomModal from "@/components/CustomModal";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { isChild } from "@/utils/fare-utils";
|
||||
|
||||
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||
|
||||
const buildSeatLabel = (seat: any): string => {
|
||||
const base: string = seat.number || seat.label || seat.seatNumber || '';
|
||||
if (!base) return '';
|
||||
const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : '';
|
||||
return suffix ? `${base}${suffix}` : base;
|
||||
};
|
||||
|
||||
const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
|
||||
const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
|
||||
const bedPosition = bed.bedPosition || "";
|
||||
const bedType =
|
||||
bedPosition === "upper"
|
||||
? "Upper"
|
||||
: bedPosition === "middle"
|
||||
? "Middle"
|
||||
: "Lower";
|
||||
const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(bed.id)}
|
||||
disabled={isDisabled}
|
||||
title={
|
||||
isAssignedToOther
|
||||
? `Bed ${seatLabel} - already assigned to another passenger`
|
||||
: `${bedType} Berth ${seatLabel} - ${bed.status}`
|
||||
}
|
||||
className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
|
||||
isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
|
||||
: isAssignedToOther
|
||||
? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
|
||||
: bed.status === "AVAILABLE"
|
||||
? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
|
||||
: bed.status === "BOOKED" || bed.status === "BLOCKED"
|
||||
? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
|
||||
: "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
{/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
|
||||
berth lies horizontally, matching the direction beds actually run in the coach. */}
|
||||
<div className="w-9 h-6 flex items-center justify-center overflow-visible">
|
||||
<Image src="/bed.png" alt="bed" width={22} height={36} className="object-contain rotate-90" />
|
||||
</div>
|
||||
<div className="text-xs font-bold text-gray-900 dark:text-white">
|
||||
{seatLabel}
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400">
|
||||
{bedType}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
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(() => (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center flex-shrink-0 self-stretch w-6 sm:w-7 py-1.5"
|
||||
title="Ladder to the middle & upper berths"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 90" preserveAspectRatio="none" className="text-gray-400 dark:text-gray-500 drop-shadow-sm">
|
||||
{/* Side rails */}
|
||||
<rect x="2" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
|
||||
<rect x="18.5" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
|
||||
{/* Rungs, evenly spaced top (upper) to bottom (lower) */}
|
||||
<rect x="2" y="6" width="20" height="4" rx="2" fill="currentColor" />
|
||||
<rect x="2" y="30" width="20" height="4" rx="2" fill="currentColor" />
|
||||
<rect x="2" y="54" width="20" height="4" rx="2" fill="currentColor" />
|
||||
<rect x="2" y="78" width="20" height="4" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
));
|
||||
|
||||
LadderConnector.displayName = "LadderConnector";
|
||||
|
||||
const SeatButton = memo(
|
||||
({
|
||||
seat,
|
||||
isSelected,
|
||||
isAssignedToOther,
|
||||
onToggle,
|
||||
isBedCoach,
|
||||
bedLabel,
|
||||
coachSeatClass,
|
||||
}: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || "?";
|
||||
const bedWidth = "w-24";
|
||||
const width = isBedCoach ? bedWidth : "w-10";
|
||||
const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => onToggle(seat.id)}
|
||||
disabled={isDisabled}
|
||||
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
|
||||
: isAssignedToOther
|
||||
? "bg-purple-400 text-white cursor-not-allowed opacity-75"
|
||||
: seat.status === "AVAILABLE"
|
||||
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
|
||||
: seat.status === "HELD"
|
||||
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
|
||||
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
|
||||
}`}
|
||||
title={
|
||||
isAssignedToOther
|
||||
? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
|
||||
: `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
|
||||
}
|
||||
style={
|
||||
isBedCoach
|
||||
? seat.row % 2 === 1
|
||||
? { transform: "scaleY(-1)" }
|
||||
: undefined
|
||||
: seat.row % 2 === 0
|
||||
? { transform: "scaleY(-1)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isBedCoach ? (
|
||||
<Bed className="w-7 h-7" />
|
||||
) : (
|
||||
<Armchair className="w-7 h-7" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SeatButton.displayName = "SeatButton";
|
||||
import {
|
||||
buildSeatLabel,
|
||||
CoachSeatLayout,
|
||||
getBedPosition,
|
||||
getValidSeatsForCoach as getValidSeatsForCoachData,
|
||||
} from "@/components/SeatMap";
|
||||
|
||||
export default function SeatsPage() {
|
||||
const router = useRouter();
|
||||
@@ -701,72 +562,11 @@ export default function SeatsPage() {
|
||||
[filteredCoaches, selectedCoach],
|
||||
);
|
||||
|
||||
const getBedPosition = (selectedClass: string): string | null => {
|
||||
const lowerClass = selectedClass.toLowerCase();
|
||||
if (lowerClass.includes("upper")) return "upper";
|
||||
if (lowerClass.includes("middle")) return "middle";
|
||||
if (lowerClass.includes("lower")) return "lower";
|
||||
return null;
|
||||
};
|
||||
|
||||
// Extracted so it can be applied to ANY coach, not just the one currently expanded —
|
||||
// Auto Assign needs to look across every coach of this type, not just selectedCoachData.
|
||||
// Berth-class narrowing lives in the shared SeatMap module so the booking and reschedule
|
||||
// flows filter beds identically; this wrapper just binds the current leg's fare class.
|
||||
const getValidSeatsForCoach = useCallback(
|
||||
(coachData: any): any[] => {
|
||||
if (!coachData) return [];
|
||||
|
||||
// If coach has rooms, extract all beds from rooms
|
||||
if (coachData.rooms?.length > 0) {
|
||||
const allBeds: any[] = [];
|
||||
coachData.rooms.forEach((room: any) => {
|
||||
if (room.beds) {
|
||||
allBeds.push(...room.beds);
|
||||
}
|
||||
});
|
||||
|
||||
let beds = allBeds.filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
const isBedCoach =
|
||||
coachData.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData.mode?.toLowerCase().includes("bed");
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return beds;
|
||||
}
|
||||
|
||||
// Fallback to old seat structure
|
||||
let seats = (coachData.seats || []).filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
const isBedCoach =
|
||||
coachData.isBedCoach === true ||
|
||||
seats.some((s: any) => s.bedPosition) ||
|
||||
coachData.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData.mode?.toLowerCase().includes("bed");
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return seats;
|
||||
},
|
||||
(coachData: any): any[] =>
|
||||
getValidSeatsForCoachData(coachData, currentSchedule?.selectedSeatClass),
|
||||
[currentSchedule?.selectedSeatClass],
|
||||
);
|
||||
|
||||
@@ -1369,355 +1169,6 @@ export default function SeatsPage() {
|
||||
}
|
||||
}, [seatEligibility, seatEligibleIndices, activePassengerIndex]);
|
||||
|
||||
const parseSeatArrangement = (
|
||||
arrangement: string | null,
|
||||
seatClasses?: string[],
|
||||
): number[] => {
|
||||
if (!arrangement) return [2, 2];
|
||||
|
||||
// Check if this is a bed coach based on seat classes
|
||||
const isBedCoach = seatClasses?.some((sc) =>
|
||||
sc?.toLowerCase().includes("bed"),
|
||||
);
|
||||
|
||||
if (isBedCoach) {
|
||||
// For bed coaches, arrangement like "3+0" means 3 beds stacked vertically
|
||||
// We want to render them as single column, so return [1]
|
||||
const parts = arrangement
|
||||
.split("+")
|
||||
.map((p) => parseInt(p.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0);
|
||||
return parts.length > 0 ? [Math.max(...parts)] : [3];
|
||||
}
|
||||
|
||||
// For regular seats, parse normally (e.g., "3+2" -> [3, 2])
|
||||
const parts = arrangement
|
||||
.split("+")
|
||||
.map((p) => parseInt(p.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0);
|
||||
return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
|
||||
};
|
||||
|
||||
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
||||
const arrangement = parseSeatArrangement(
|
||||
coach.seatArrangement,
|
||||
coach.seatClasses || [coach.seatClass],
|
||||
);
|
||||
|
||||
if (validSeats.length === 0) {
|
||||
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||
}
|
||||
|
||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||
const seatClassStr =
|
||||
typeof selectedCoachData?.seatClass === "string"
|
||||
? 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) => (
|
||||
<div className="flex items-stretch gap-2">
|
||||
{beds.map((bed: any) => (
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={handleSeatClick}
|
||||
/>
|
||||
))}
|
||||
{beds.length > 1 && <LadderConnector key={`${keyPrefix}-ladder`} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-gray-50 dark:bg-gray-800/40 rounded-2xl p-4 border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{leftBay.length > 0 && (
|
||||
<div className="flex justify-center">{renderBerthBay(leftBay, `${key}-left`)}</div>
|
||||
)}
|
||||
{leftBay.length > 0 && rightBay.length > 0 && (
|
||||
<div className="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600" />
|
||||
)}
|
||||
{rightBay.length > 0 && (
|
||||
<div className="flex justify-center">{renderBerthBay(rightBay, `${key}-right`)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Bay position ordering + left/right side detection shared by both bed layouts below.
|
||||
const BERTH_ORDER = ["lower", "middle", "upper"];
|
||||
const bedSideIsLeft = (bed: any, leftColByPosition: Record<string, string>) => {
|
||||
if (bed.position === "LEFT") return true;
|
||||
if (bed.position === "RIGHT") return false;
|
||||
const leftCol = leftColByPosition[bed.bedPosition];
|
||||
return leftCol ? bed.col === leftCol : true;
|
||||
};
|
||||
|
||||
// Bed coach with bed positions (Upper, Middle, Lower)
|
||||
if (isBedCoach && hasBedPositionData) {
|
||||
// Check if this is VIP_BED or ECONOMY_BED based on room data
|
||||
const rooms = (coach as any).rooms || [];
|
||||
const hasRooms = rooms.length > 0;
|
||||
|
||||
if (hasRooms) {
|
||||
// Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{rooms.map((room: any) => {
|
||||
const isVipBed =
|
||||
room.category === "VIP_BED" || room.totalBeds === 4;
|
||||
const isEconomyBed =
|
||||
room.category === "ECONOMY_BED" || room.totalBeds === 6;
|
||||
|
||||
// Sort beds by position and column
|
||||
const sortedBeds = [...(room.beds || [])].sort((a, b) => {
|
||||
const posOrder = { upper: 3, middle: 2, lower: 1 };
|
||||
const posA =
|
||||
posOrder[a.bedPosition as keyof typeof posOrder] || 0;
|
||||
const posB =
|
||||
posOrder[b.bedPosition as keyof typeof posOrder] || 0;
|
||||
if (posA !== posB) return posA - posB;
|
||||
return (a.col || "").localeCompare(b.col || "");
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.room_id}
|
||||
className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border-2 border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Room Header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-300 dark:border-gray-600">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
Room {room.roomNumber}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{room.category === "VIP_BED"
|
||||
? "VIP BED"
|
||||
: room.category === "ECONOMY_BED"
|
||||
? "ECONOMY BED"
|
||||
: room.category}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{room.totalBeds} beds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-2 mb-4 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-green-50 border border-green-300 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Available
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-red-50 border border-red-300 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Booked
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */}
|
||||
{isVipBed && (() => {
|
||||
const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower");
|
||||
const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper");
|
||||
const isLeft = (bed: any, idx: number) =>
|
||||
bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0;
|
||||
|
||||
const leftBay = [lowerBeds, upperBeds]
|
||||
.map((arr) => arr.find((b: any, i: number) => isLeft(b, i)))
|
||||
.filter(Boolean);
|
||||
const rightBay = [lowerBeds, upperBeds]
|
||||
.map((arr) => arr.find((b: any, i: number) => !isLeft(b, i)))
|
||||
.filter(Boolean);
|
||||
|
||||
return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`);
|
||||
})()}
|
||||
|
||||
{/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */}
|
||||
{isEconomyBed && (() => {
|
||||
const leftColByPosition: Record<string, string> = { lower: "A", middle: "B", upper: "C" };
|
||||
const leftBay = BERTH_ORDER
|
||||
.map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition)))
|
||||
.filter(Boolean);
|
||||
const rightBay = BERTH_ORDER
|
||||
.map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition)))
|
||||
.filter(Boolean);
|
||||
|
||||
return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper),
|
||||
// then pair adjacent bays into two-side compartments, same as the room-based layouts.
|
||||
const seatGroups = new Map<string, any[]>();
|
||||
|
||||
for (const seat of validSeats) {
|
||||
const baseNumber = seat.seatNumber || seat.number || seat.label || "";
|
||||
if (!seatGroups.has(baseNumber)) {
|
||||
seatGroups.set(baseNumber, []);
|
||||
}
|
||||
seatGroups.get(baseNumber)!.push(seat);
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => {
|
||||
const numA = parseInt(a) || 0;
|
||||
const numB = parseInt(b) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
const bays = sortedGroups
|
||||
.map(([, beds]) =>
|
||||
BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean),
|
||||
)
|
||||
.filter((bay) => bay.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => {
|
||||
const leftBay = bays[i * 2] || [];
|
||||
const rightBay = bays[i * 2 + 1] || [];
|
||||
return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular seats with row/column arrangement
|
||||
const rowMap = new Map<number, any[]>();
|
||||
for (const seat of validSeats) {
|
||||
if (!rowMap.has(seat.row)) {
|
||||
rowMap.set(seat.row, []);
|
||||
}
|
||||
rowMap.get(seat.row)!.push(seat);
|
||||
}
|
||||
|
||||
const rows = Array.from(rowMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{rows.map((rowSeats: any[], rowIdx: number) => {
|
||||
const groups: any[][] = [];
|
||||
|
||||
// Split seats into groups based on arrangement
|
||||
if (arrangement.length === 1) {
|
||||
// Single group (all seats together)
|
||||
groups.push(rowSeats);
|
||||
} else {
|
||||
// Multiple groups with aisle separation
|
||||
arrangement.forEach((_groupSize, groupIdx) => {
|
||||
const startIdx = arrangement
|
||||
.slice(0, groupIdx)
|
||||
.reduce((sum, size) => sum + size, 0);
|
||||
const endIdx = arrangement
|
||||
.slice(0, groupIdx + 1)
|
||||
.reduce((sum, size) => sum + size, 0);
|
||||
const currentGroup = rowSeats.slice(startIdx, endIdx);
|
||||
if (currentGroup.length > 0) groups.push(currentGroup);
|
||||
});
|
||||
}
|
||||
|
||||
const rowNumber = rowSeats[0]?.row || 1;
|
||||
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||
const showSpacing = rowIdx % 2 === 1;
|
||||
|
||||
return (
|
||||
<div key={`row-${rowNumber}-${rowSeats[0]?.id}`}>
|
||||
{shouldFlipArmchair && (
|
||||
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
|
||||
{groups.map((group, gIdx) => (
|
||||
<div
|
||||
key={`num-before-group-${gIdx}`}
|
||||
className="flex gap-0.5"
|
||||
>
|
||||
{group.map((seat: any) => {
|
||||
const seatLabel =
|
||||
seat.label || seat.number || seat.seatNumber || "";
|
||||
return (
|
||||
<div
|
||||
key={`num-${seat.id}`}
|
||||
className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground"
|
||||
>
|
||||
{seatLabel}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 justify-start">
|
||||
{groups.map((group, gIdx) => (
|
||||
<div key={`group-${gIdx}`} className="flex gap-0.5">
|
||||
{group.map((seat: any) => (
|
||||
<SeatButton
|
||||
key={seat.id}
|
||||
seat={seat}
|
||||
isSelected={isSeatSelected(seat.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(seat.id)}
|
||||
onToggle={handleSeatClick}
|
||||
isBedCoach={false}
|
||||
bedLabel=""
|
||||
coachSeatClass={seatClassStr}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!shouldFlipArmchair && (
|
||||
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
|
||||
{groups.map((group, gIdx) => (
|
||||
<div
|
||||
key={`num-after-group-${gIdx}`}
|
||||
className="flex gap-0.5"
|
||||
>
|
||||
{group.map((seat: any) => {
|
||||
const seatLabel =
|
||||
seat.label || seat.number || seat.seatNumber || "";
|
||||
return (
|
||||
<div
|
||||
key={`num-${seat.id}`}
|
||||
className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground"
|
||||
>
|
||||
{seatLabel}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSpacing && (
|
||||
<div className="h-3 border-b border-gray-200 dark:border-gray-700" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (
|
||||
isRoundTrip
|
||||
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
|
||||
@@ -2414,7 +1865,14 @@ export default function SeatsPage() {
|
||||
No seats in this coach
|
||||
</p>
|
||||
) : (
|
||||
renderCoachSeats(selectedCoachData, isBedCoach)
|
||||
<CoachSeatLayout
|
||||
coach={selectedCoachData}
|
||||
isBedCoach={isBedCoach}
|
||||
seats={validSeats}
|
||||
isSeatSelected={isSeatSelected}
|
||||
isSeatAssignedToOther={isSeatAssignedToOther}
|
||||
onSeatToggle={handleSeatClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
706
apps/edr-passenger-web/portal/src/components/SeatMap.tsx
Normal file
706
apps/edr-passenger-web/portal/src/components/SeatMap.tsx
Normal file
@@ -0,0 +1,706 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import { Armchair, Bed } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The seat map shared by the booking flow (`/booking/seats`) and the reschedule flow
|
||||
* (`/booking/reschedule`). Everything here is presentational: it takes a coach from
|
||||
* `GET /seats/seatmap/:scheduleId` and three callbacks, and knows nothing about bookings,
|
||||
* passengers, holds or fares. Both pages must render seats identically, so this is the one
|
||||
* copy — extend it rather than forking a second layout.
|
||||
*/
|
||||
|
||||
export const BED_POSITION_SUFFIX: Record<string, string> = {
|
||||
lower: "L",
|
||||
middle: "M",
|
||||
upper: "U",
|
||||
};
|
||||
|
||||
export const buildSeatLabel = (seat: any): string => {
|
||||
const base: string = seat.number || seat.label || seat.seatNumber || "";
|
||||
if (!base) return "";
|
||||
const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? "") : "";
|
||||
return suffix ? `${base}${suffix}` : base;
|
||||
};
|
||||
|
||||
/** "Economy Bed - Upper" → "upper". Null when the class names no berth level. */
|
||||
export const getBedPosition = (selectedClass: string): string | null => {
|
||||
const lowerClass = selectedClass.toLowerCase();
|
||||
if (lowerClass.includes("upper")) return "upper";
|
||||
if (lowerClass.includes("middle")) return "middle";
|
||||
if (lowerClass.includes("lower")) return "lower";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isBedCoachData = (coachData: any): boolean =>
|
||||
coachData?.isBedCoach === true ||
|
||||
coachData?.rooms?.length > 0 ||
|
||||
(coachData?.seats || []).some((s: any) => s.bedPosition) ||
|
||||
coachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData?.mode?.toLowerCase().includes("bed");
|
||||
|
||||
/**
|
||||
* Flattens a coach into the seats that are actually selectable: beds out of `rooms` when the
|
||||
* coach has them, otherwise `seats`. Placeholder rows (labels starting "-") are dropped, and
|
||||
* on a bed coach a berth-specific fare class narrows the list to that level.
|
||||
*/
|
||||
export const getValidSeatsForCoach = (
|
||||
coachData: any,
|
||||
selectedSeatClass?: string | null,
|
||||
): any[] => {
|
||||
if (!coachData) return [];
|
||||
|
||||
if (coachData.rooms?.length > 0) {
|
||||
const allBeds: any[] = [];
|
||||
coachData.rooms.forEach((room: any) => {
|
||||
if (room.beds) allBeds.push(...room.beds);
|
||||
});
|
||||
|
||||
let beds = allBeds.filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
if (isBedCoachData(coachData) && selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(selectedSeatClass);
|
||||
if (selectedBedPosition) {
|
||||
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
return beds;
|
||||
}
|
||||
|
||||
let seats = (coachData.seats || []).filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
if (isBedCoachData(coachData) && selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(selectedSeatClass);
|
||||
if (selectedBedPosition) {
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
return seats;
|
||||
};
|
||||
|
||||
/** "3+2" → [3, 2] so the aisle gap lands between the groups. Bed coaches collapse to one column. */
|
||||
export const parseSeatArrangement = (
|
||||
arrangement: string | null,
|
||||
seatClasses?: (string | undefined)[],
|
||||
): number[] => {
|
||||
if (!arrangement) return [2, 2];
|
||||
|
||||
const isBedCoach = seatClasses?.some((sc) => sc?.toLowerCase().includes("bed"));
|
||||
|
||||
if (isBedCoach) {
|
||||
// For bed coaches, arrangement like "3+0" means 3 beds stacked vertically —
|
||||
// render them as a single column.
|
||||
const parts = arrangement
|
||||
.split("+")
|
||||
.map((p) => parseInt(p.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0);
|
||||
return parts.length > 0 ? [Math.max(...parts)] : [3];
|
||||
}
|
||||
|
||||
const parts = arrangement
|
||||
.split("+")
|
||||
.map((p) => parseInt(p.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0);
|
||||
return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
|
||||
};
|
||||
|
||||
export const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
|
||||
const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
|
||||
const bedPosition = bed.bedPosition || "";
|
||||
const bedType =
|
||||
bedPosition === "upper" ? "Upper" : bedPosition === "middle" ? "Middle" : "Lower";
|
||||
const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(bed.id)}
|
||||
disabled={isDisabled}
|
||||
title={
|
||||
isAssignedToOther
|
||||
? `Bed ${seatLabel} - already assigned to another passenger`
|
||||
: `${bedType} Berth ${seatLabel} - ${bed.status}`
|
||||
}
|
||||
className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
|
||||
isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
|
||||
: isAssignedToOther
|
||||
? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
|
||||
: bed.status === "AVAILABLE"
|
||||
? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
|
||||
: bed.status === "BOOKED" || bed.status === "BLOCKED"
|
||||
? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
|
||||
: "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
{/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
|
||||
berth lies horizontally, matching the direction beds actually run in the coach. */}
|
||||
<div className="w-9 h-6 flex items-center justify-center overflow-visible">
|
||||
<Image src="/bed.png" alt="bed" width={22} height={36} className="object-contain rotate-90" />
|
||||
</div>
|
||||
<div className="text-xs font-bold text-gray-900 dark:text-white">{seatLabel}</div>
|
||||
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400">{bedType}</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
export const LadderConnector = memo(() => (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center flex-shrink-0 self-stretch w-6 sm:w-7 py-1.5"
|
||||
title="Ladder to the middle & upper berths"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 90" preserveAspectRatio="none" className="text-gray-400 dark:text-gray-500 drop-shadow-sm">
|
||||
{/* Side rails */}
|
||||
<rect x="2" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
|
||||
<rect x="18.5" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
|
||||
{/* Rungs, evenly spaced top (upper) to bottom (lower) */}
|
||||
<rect x="2" y="6" width="20" height="4" rx="2" fill="currentColor" />
|
||||
<rect x="2" y="30" width="20" height="4" rx="2" fill="currentColor" />
|
||||
<rect x="2" y="54" width="20" height="4" rx="2" fill="currentColor" />
|
||||
<rect x="2" y="78" width="20" height="4" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
));
|
||||
|
||||
LadderConnector.displayName = "LadderConnector";
|
||||
|
||||
export const SeatButton = memo(
|
||||
({ seat, isSelected, isAssignedToOther, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || "?";
|
||||
const bedWidth = "w-24";
|
||||
const width = isBedCoach ? bedWidth : "w-10";
|
||||
const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => onToggle(seat.id)}
|
||||
disabled={isDisabled}
|
||||
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
|
||||
: isAssignedToOther
|
||||
? "bg-purple-400 text-white cursor-not-allowed opacity-75"
|
||||
: seat.status === "AVAILABLE"
|
||||
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
|
||||
: seat.status === "HELD"
|
||||
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
|
||||
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
|
||||
}`}
|
||||
title={
|
||||
isAssignedToOther
|
||||
? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
|
||||
: `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
|
||||
}
|
||||
style={
|
||||
isBedCoach
|
||||
? seat.row % 2 === 1
|
||||
? { transform: "scaleY(-1)" }
|
||||
: undefined
|
||||
: seat.row % 2 === 0
|
||||
? { transform: "scaleY(-1)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isBedCoach ? <Bed className="w-7 h-7" /> : <Armchair className="w-7 h-7" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SeatButton.displayName = "SeatButton";
|
||||
|
||||
/** Available / Selected / Booked swatches, shown above every expanded coach. */
|
||||
export function SeatLegend() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
{[
|
||||
{ color: "bg-green-50 border border-green-300", label: "Available" },
|
||||
{ color: "bg-blue-50 border-2 border-blue-500", label: "Selected" },
|
||||
{ color: "bg-red-50 border border-red-300", label: "Booked" },
|
||||
].map(({ color, label }) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div className={`w-4 h-4 ${color} rounded`} />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CoachSeatLayoutProps {
|
||||
coach: any;
|
||||
isBedCoach: boolean;
|
||||
/** Already filtered by `getValidSeatsForCoach` — the caller owns berth-class narrowing. */
|
||||
seats: any[];
|
||||
isSeatSelected: (seatId: string) => boolean;
|
||||
isSeatAssignedToOther: (seatId: string) => boolean;
|
||||
onSeatToggle: (seatId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seat grid for one coach. Four layouts, picked off the coach's own shape:
|
||||
* room-based VIP (4 berths), room-based Economy (6 berths), room-less berth bays, and
|
||||
* regular rows with the aisle gap from `seatArrangement`.
|
||||
*/
|
||||
export function CoachSeatLayout({
|
||||
coach,
|
||||
isBedCoach,
|
||||
seats: validSeats,
|
||||
isSeatSelected,
|
||||
isSeatAssignedToOther,
|
||||
onSeatToggle,
|
||||
}: CoachSeatLayoutProps) {
|
||||
const arrangement = parseSeatArrangement(
|
||||
coach?.seatArrangement,
|
||||
coach?.seatClasses || [coach?.seatClass],
|
||||
);
|
||||
|
||||
if (validSeats.length === 0) {
|
||||
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||
}
|
||||
|
||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||
const seatClassStr =
|
||||
typeof coach?.seatClass === "string" ? coach.seatClass : coach?.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) => (
|
||||
<div className="flex items-stretch gap-2">
|
||||
{beds.map((bed: any) => (
|
||||
<BedCard
|
||||
key={bed.id}
|
||||
bed={bed}
|
||||
isSelected={isSeatSelected(bed.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(bed.id)}
|
||||
onToggle={onSeatToggle}
|
||||
/>
|
||||
))}
|
||||
{beds.length > 1 && <LadderConnector key={`${keyPrefix}-ladder`} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-gray-50 dark:bg-gray-800/40 rounded-2xl p-4 border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{leftBay.length > 0 && (
|
||||
<div className="flex justify-center">{renderBerthBay(leftBay, `${key}-left`)}</div>
|
||||
)}
|
||||
{leftBay.length > 0 && rightBay.length > 0 && (
|
||||
<div className="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600" />
|
||||
)}
|
||||
{rightBay.length > 0 && (
|
||||
<div className="flex justify-center">{renderBerthBay(rightBay, `${key}-right`)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Bay position ordering + left/right side detection shared by both bed layouts below.
|
||||
const BERTH_ORDER = ["lower", "middle", "upper"];
|
||||
const bedSideIsLeft = (bed: any, leftColByPosition: Record<string, string>) => {
|
||||
if (bed.position === "LEFT") return true;
|
||||
if (bed.position === "RIGHT") return false;
|
||||
const leftCol = leftColByPosition[bed.bedPosition];
|
||||
return leftCol ? bed.col === leftCol : true;
|
||||
};
|
||||
|
||||
if (isBedCoach && hasBedPositionData) {
|
||||
const rooms = (coach as any)?.rooms || [];
|
||||
|
||||
if (rooms.length > 0) {
|
||||
// Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{rooms.map((room: any) => {
|
||||
const isVipBed = room.category === "VIP_BED" || room.totalBeds === 4;
|
||||
const isEconomyBed = room.category === "ECONOMY_BED" || room.totalBeds === 6;
|
||||
|
||||
const sortedBeds = [...(room.beds || [])].sort((a, b) => {
|
||||
const posOrder = { upper: 3, middle: 2, lower: 1 };
|
||||
const posA = posOrder[a.bedPosition as keyof typeof posOrder] || 0;
|
||||
const posB = posOrder[b.bedPosition as keyof typeof posOrder] || 0;
|
||||
if (posA !== posB) return posA - posB;
|
||||
return (a.col || "").localeCompare(b.col || "");
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.room_id}
|
||||
className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border-2 border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Room Header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-300 dark:border-gray-600">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
Room {room.roomNumber}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{room.category === "VIP_BED"
|
||||
? "VIP BED"
|
||||
: room.category === "ECONOMY_BED"
|
||||
? "ECONOMY BED"
|
||||
: room.category}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{room.totalBeds} beds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-2 mb-4 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-green-50 border border-green-300 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-red-50 border border-red-300 rounded" />
|
||||
<span className="text-gray-600 dark:text-gray-400">Booked</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */}
|
||||
{isVipBed && (() => {
|
||||
const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower");
|
||||
const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper");
|
||||
const isLeft = (bed: any, idx: number) =>
|
||||
bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0;
|
||||
|
||||
const leftBay = [lowerBeds, upperBeds]
|
||||
.map((arr) => arr.find((b: any, i: number) => isLeft(b, i)))
|
||||
.filter(Boolean);
|
||||
const rightBay = [lowerBeds, upperBeds]
|
||||
.map((arr) => arr.find((b: any, i: number) => !isLeft(b, i)))
|
||||
.filter(Boolean);
|
||||
|
||||
return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`);
|
||||
})()}
|
||||
|
||||
{/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */}
|
||||
{isEconomyBed && (() => {
|
||||
const leftColByPosition: Record<string, string> = { lower: "A", middle: "B", upper: "C" };
|
||||
const leftBay = BERTH_ORDER
|
||||
.map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition)))
|
||||
.filter(Boolean);
|
||||
const rightBay = BERTH_ORDER
|
||||
.map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition)))
|
||||
.filter(Boolean);
|
||||
|
||||
return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper),
|
||||
// then pair adjacent bays into two-side compartments, same as the room-based layouts.
|
||||
const seatGroups = new Map<string, any[]>();
|
||||
for (const seat of validSeats) {
|
||||
const baseNumber = seat.seatNumber || seat.number || seat.label || "";
|
||||
if (!seatGroups.has(baseNumber)) seatGroups.set(baseNumber, []);
|
||||
seatGroups.get(baseNumber)!.push(seat);
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => {
|
||||
const numA = parseInt(a) || 0;
|
||||
const numB = parseInt(b) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
const bays = sortedGroups
|
||||
.map(([, beds]) =>
|
||||
BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean),
|
||||
)
|
||||
.filter((bay) => bay.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => {
|
||||
const leftBay = bays[i * 2] || [];
|
||||
const rightBay = bays[i * 2 + 1] || [];
|
||||
return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular seats with row/column arrangement
|
||||
const rowMap = new Map<number, any[]>();
|
||||
for (const seat of validSeats) {
|
||||
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
|
||||
rowMap.get(seat.row)!.push(seat);
|
||||
}
|
||||
|
||||
const rows = Array.from(rowMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
|
||||
|
||||
const renderSeatNumberStrip = (groups: any[][], keyPrefix: string) => (
|
||||
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
|
||||
{groups.map((group, gIdx) => (
|
||||
<div key={`${keyPrefix}-group-${gIdx}`} className="flex gap-0.5">
|
||||
{group.map((seat: any) => (
|
||||
<div
|
||||
key={`num-${seat.id}`}
|
||||
className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground"
|
||||
>
|
||||
{seat.label || seat.number || seat.seatNumber || ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{rows.map((rowSeats: any[], rowIdx: number) => {
|
||||
const groups: any[][] = [];
|
||||
|
||||
if (arrangement.length === 1) {
|
||||
groups.push(rowSeats);
|
||||
} else {
|
||||
arrangement.forEach((_groupSize, groupIdx) => {
|
||||
const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0);
|
||||
const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0);
|
||||
const currentGroup = rowSeats.slice(startIdx, endIdx);
|
||||
if (currentGroup.length > 0) groups.push(currentGroup);
|
||||
});
|
||||
}
|
||||
|
||||
const rowNumber = rowSeats[0]?.row || 1;
|
||||
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||
const showSpacing = rowIdx % 2 === 1;
|
||||
|
||||
return (
|
||||
<div key={`row-${rowNumber}-${rowSeats[0]?.id}`}>
|
||||
{shouldFlipArmchair && renderSeatNumberStrip(groups, `before-${rowNumber}`)}
|
||||
|
||||
<div className="flex gap-3 justify-start">
|
||||
{groups.map((group, gIdx) => (
|
||||
<div key={`group-${gIdx}`} className="flex gap-0.5">
|
||||
{group.map((seat: any) => (
|
||||
<SeatButton
|
||||
key={seat.id}
|
||||
seat={seat}
|
||||
isSelected={isSeatSelected(seat.id)}
|
||||
isAssignedToOther={isSeatAssignedToOther(seat.id)}
|
||||
onToggle={onSeatToggle}
|
||||
isBedCoach={false}
|
||||
bedLabel=""
|
||||
coachSeatClass={seatClassStr}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!shouldFlipArmchair && renderSeatNumberStrip(groups, `after-${rowNumber}`)}
|
||||
|
||||
{showSpacing && <div className="h-3 border-b border-gray-200 dark:border-gray-700" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SeatMapProps {
|
||||
/** Coaches straight off `GET /seats/seatmap/:scheduleId`. */
|
||||
coaches: any[];
|
||||
selectedCoachId: string | null;
|
||||
onSelectCoach: (coachId: string | null) => void;
|
||||
/** Berth-level fare class, e.g. "Economy Bed - Upper". Narrows a bed coach to one level. */
|
||||
selectedSeatClass?: string | null;
|
||||
isSeatSelected: (seatId: string) => boolean;
|
||||
isSeatAssignedToOther: (seatId: string) => boolean;
|
||||
onSeatToggle: (seatId: string) => void;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coach accordion + legend + seat grid, styled as the train itself: coupling joints between
|
||||
* cars, a brand stripe top and bottom, and per-coach availability bars.
|
||||
*/
|
||||
export default function SeatMap({
|
||||
coaches,
|
||||
selectedCoachId,
|
||||
onSelectCoach,
|
||||
selectedSeatClass,
|
||||
isSeatSelected,
|
||||
isSeatAssignedToOther,
|
||||
onSeatToggle,
|
||||
emptyLabel = "No coach of your class on this train.",
|
||||
}: SeatMapProps) {
|
||||
if (!coaches || coaches.length === 0) {
|
||||
return <p className="text-sm text-gray-500 dark:text-gray-400">{emptyLabel}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{coaches.map((coach: any, index: number) => {
|
||||
const coachSeats = getValidSeatsForCoach(coach, selectedSeatClass);
|
||||
const available = coachSeats.filter((s: any) => s.status === "AVAILABLE").length;
|
||||
const total = coachSeats.length;
|
||||
const isExpanded = selectedCoachId === coach.id;
|
||||
const isBedCoach = isBedCoachData(coach);
|
||||
const coachLabel = coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`;
|
||||
|
||||
return (
|
||||
<div key={coach.id}>
|
||||
{/* Coupling joint */}
|
||||
<div className="flex justify-center py-0.5">
|
||||
<div className="flex flex-col items-center gap-px">
|
||||
<div className="w-7 h-2 bg-gray-300 dark:bg-gray-600 rounded-sm" />
|
||||
<div className="w-3 h-3 bg-gray-400 dark:bg-gray-500 rounded-sm" />
|
||||
<div className="w-7 h-2 bg-gray-300 dark:bg-gray-600 rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coach car */}
|
||||
<div
|
||||
className={`border-2 overflow-hidden transition-all duration-200 ${
|
||||
isExpanded
|
||||
? "border-[rgb(20,113,76)] shadow-lg shadow-[rgb(20,113,76)]/10"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{/* Top colour stripe — brand rail */}
|
||||
<div
|
||||
className={`h-1.5 transition-colors duration-200 ${
|
||||
isExpanded ? "bg-[rgb(20,113,76)]" : "bg-gray-200 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectCoach(isExpanded ? null : coach.id)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 transition-colors ${
|
||||
isExpanded
|
||||
? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10"
|
||||
: "bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 font-bold text-sm transition-colors ${
|
||||
isExpanded
|
||||
? "bg-[rgb(20,113,76)] text-white"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div
|
||||
className={`font-semibold text-sm ${
|
||||
isExpanded ? "text-[rgb(20,113,76)]" : "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{coachLabel}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{available} of {total} seats available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mini availability bars */}
|
||||
<div className="hidden sm:flex items-end gap-0.5 h-5">
|
||||
{Array.from({ length: Math.min(total, 12) }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 rounded-sm transition-colors ${
|
||||
i < Math.round((available / Math.max(total, 1)) * Math.min(total, 12))
|
||||
? "h-full bg-green-400"
|
||||
: "h-3 bg-gray-200 dark:bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ChevronDownIcon isExpanded={isExpanded} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded seat map */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-800 p-4">
|
||||
<SeatLegend />
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
|
||||
{coachSeats.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 py-4">No seats in this coach</p>
|
||||
) : (
|
||||
<CoachSeatLayout
|
||||
coach={coach}
|
||||
isBedCoach={isBedCoach}
|
||||
seats={coachSeats}
|
||||
isSeatSelected={isSeatSelected}
|
||||
isSeatAssignedToOther={isSeatAssignedToOther}
|
||||
onSeatToggle={onSeatToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom colour stripe */}
|
||||
<div
|
||||
className={`h-1.5 transition-colors duration-200 ${
|
||||
isExpanded ? "bg-[rgb(20,113,76)]" : "bg-gray-200 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ isExpanded }: { isExpanded: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`w-4 h-4 transition-transform duration-200 flex-shrink-0 ${
|
||||
isExpanded ? "rotate-180 text-[rgb(20,113,76)]" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user