feat: (reschedule) share the booking flow's seat map and add a sticky change summary

This commit is contained in:
Abubeker Yasin
2026-08-22 09:36:00 +03:00
parent 9843b64989
commit 0fd6a1d7d7
3 changed files with 1056 additions and 632 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Suspense, useEffect, useMemo, useState } from "react"; import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -11,6 +11,7 @@ import StationDropdown, {
pushRecentStation, pushRecentStation,
readRecentStationIds, readRecentStationIds,
} from "@/components/StationDropdown"; } from "@/components/StationDropdown";
import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
import { formatTime } from "@/utils/format"; import { formatTime } from "@/utils/format";
import { Station } from "@/types"; import { Station } from "@/types";
@@ -70,6 +71,13 @@ type Quote = {
const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; 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() { function ReschedulePageContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -81,7 +89,12 @@ function ReschedulePageContent() {
const [destinationId, setDestinationId] = useState(""); const [destinationId, setDestinationId] = useState("");
const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null); const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null);
const [schedule, setSchedule] = useState<any | 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 [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dateNotice, setDateNotice] = useState<string | null>(null); const [dateNotice, setDateNotice] = useState<string | null>(null);
@@ -91,6 +104,12 @@ function ReschedulePageContent() {
const saveRecent = (id: string) => const saveRecent = (id: string) =>
setRecentStationIds((prev) => pushRecentStation(id, prev)); setRecentStationIds((prev) => pushRecentStation(id, prev));
const resetSeats = () => {
setPassengerSeatMap({});
setActivePassengerIndex(0);
setSelectedCoach(null);
};
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({ const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["reschedule-options", ref], queryKey: ["reschedule-options", ref],
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`), queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`),
@@ -110,7 +129,7 @@ function ReschedulePageContent() {
setOriginId(leg.originStationId ?? ""); setOriginId(leg.originStationId ?? "");
setDestinationId(leg.destinationStationId ?? ""); setDestinationId(leg.destinationStationId ?? "");
setSchedule(null); setSchedule(null);
setSeatIds([]); resetSeats();
setSearched(null); setSearched(null);
}, [leg?.leg, leg?.scheduleId]); }, [leg?.leg, leg?.scheduleId]);
@@ -163,7 +182,7 @@ function ReschedulePageContent() {
setDate(undefined); setDate(undefined);
setSearched(null); setSearched(null);
setSchedule(null); setSchedule(null);
setSeatIds([]); resetSeats();
setDateNotice("No trains run this route on that date — please pick another."); setDateNotice("No trains run this route on that date — please pick another.");
} }
}, [date, disabledDates]); }, [date, disabledDates]);
@@ -175,7 +194,7 @@ function ReschedulePageContent() {
setDate(undefined); setDate(undefined);
setSearched(null); setSearched(null);
setSchedule(null); setSchedule(null);
setSeatIds([]); resetSeats();
} }
}, [noRouteForPair, date]); }, [noRouteForPair, date]);
@@ -208,8 +227,62 @@ function ReschedulePageContent() {
enabled: !!scheduleId && !!leg, enabled: !!scheduleId && !!leg,
}); });
const quoteBody = leg && scheduleId && seatIds.length === leg.seatCount const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]);
? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds }
// 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; : null;
const { data: quote, isFetching: quoting } = useQuery<Quote>({ const { data: quote, isFetching: quoting } = useQuery<Quote>({
queryKey: ["reschedule-quote", ref, quoteBody], queryKey: ["reschedule-quote", ref, quoteBody],
@@ -224,7 +297,7 @@ function ReschedulePageContent() {
originStationId: originId, originStationId: originId,
destinationStationId: destinationId, destinationStationId: destinationId,
journeyDirection, 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 }); 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"), 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 ?? "—"; 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>; 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; 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 = const canSearch =
!!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair; !!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 ( 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"> <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 <ChevronLeft className="w-4 h-4" /> Back to booking
</button> </button>
@@ -306,12 +493,24 @@ function ReschedulePageContent() {
</div> </div>
)} )}
{leg.policy && ( <div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
<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"> {/* Left column — the choices */}
<div className="font-semibold text-gray-900 dark:text-white">Your fare rules</div> <div className="lg:col-span-2 space-y-4">
<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 className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
<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> {fareRules.length > 0 && (
<div>Changes close {leg.policy.cutoffMinutes} minutes before departure.</div> <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> </div>
)} )}
@@ -338,7 +537,7 @@ function ReschedulePageContent() {
if (s.id) saveRecent(s.id); if (s.id) saveRecent(s.id);
setDateNotice(null); setDateNotice(null);
setSchedule(null); setSchedule(null);
setSeatIds([]); resetSeats();
setSearched(null); setSearched(null);
}} }}
/> />
@@ -354,7 +553,7 @@ function ReschedulePageContent() {
if (s.id) saveRecent(s.id); if (s.id) saveRecent(s.id);
setDateNotice(null); setDateNotice(null);
setSchedule(null); setSchedule(null);
setSeatIds([]); resetSeats();
setSearched(null); setSearched(null);
}} }}
/> />
@@ -370,7 +569,7 @@ function ReschedulePageContent() {
disabled={noRouteForPair} disabled={noRouteForPair}
placeholder="New date" 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"} {searching ? "Searching..." : "Find trains"}
</button> </button>
</div> </div>
@@ -396,7 +595,7 @@ function ReschedulePageContent() {
const id = s.scheduleId || s.id; const id = s.scheduleId || s.id;
const selected = scheduleId === id; const selected = scheduleId === id;
return ( 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`}> 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>
<div className="font-semibold text-gray-900 dark:text-white">{s.trainName || s.trainNumber}</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) */} {/* Step 3: seats (same class as booked) */}
{scheduleId && ( {scheduleId && (
<div className="mb-6"> <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> <h2 className="font-semibold text-gray-900 dark:text-white mb-1">
{loadingSeats && <Loader2 className="w-5 h-5 animate-spin text-primary" />} Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({orderedSeatIds.length}/{leg.seatCount})
{(seatMap?.coaches ?? []).map((coach: any) => ( </h2>
<div key={coach.id} className="mb-3"> <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
<div className="text-xs text-gray-500 mb-1">{coach.name} · {coach.coachTypeName}</div> {allSeatsChosen
<div className="flex flex-wrap gap-1"> ? "Every passenger has a seat."
{(coach.seats ?? []).map((seat: any) => { : `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`}
const picked = seatIds.includes(seat.id); </p>
const free = seat.status === "AVAILABLE";
{/* 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 ( return (
<button key={seat.id} disabled={!free} onClick={() => toggleSeat(seat.id)} title={`${seat.seatNumber} ${seat.bedPosition ?? ""} ${seat.status}`} <button
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"}`}> key={`${name}-${i}`}
{seat.seatNumber}{seat.bedPosition ? seat.bedPosition[0].toUpperCase() : ""} 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> </button>
); );
})} })}
</div> </div>
</div>
))} {loadingSeats ? (
{seatMap && (seatMap.coaches ?? []).length === 0 && <p className="text-sm text-gray-500">No coach of your class on this train.</p>} <Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<SeatMap
coaches={coaches}
selectedCoachId={selectedCoach}
onSelectCoach={setSelectedCoach}
isSeatSelected={isSeatSelected}
isSeatAssignedToOther={isSeatAssignedToOther}
onSeatToggle={handleSeatToggle}
/>
)}
</div> </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> </div>
)}
</>
)}
</Shell> </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>; 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 ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4"> <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>
</div> </div>
); );

View File

@@ -6,157 +6,18 @@ import { useRouter } from "next/navigation";
import { useBookingStore } from "@/lib/booking-store"; import { useBookingStore } from "@/lib/booking-store";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; import { ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react";
import Image from "next/image";
import CustomModal from "@/components/CustomModal"; import CustomModal from "@/components/CustomModal";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
import { isChild } from "@/utils/fare-utils"; import { isChild } from "@/utils/fare-utils";
import {
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' }; buildSeatLabel,
CoachSeatLayout,
const buildSeatLabel = (seat: any): string => { getBedPosition,
const base: string = seat.number || seat.label || seat.seatNumber || ''; getValidSeatsForCoach as getValidSeatsForCoachData,
if (!base) return ''; } from "@/components/SeatMap";
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";
export default function SeatsPage() { export default function SeatsPage() {
const router = useRouter(); const router = useRouter();
@@ -701,72 +562,11 @@ export default function SeatsPage() {
[filteredCoaches, selectedCoach], [filteredCoaches, selectedCoach],
); );
const getBedPosition = (selectedClass: string): string | null => { // Berth-class narrowing lives in the shared SeatMap module so the booking and reschedule
const lowerClass = selectedClass.toLowerCase(); // flows filter beds identically; this wrapper just binds the current leg's fare class.
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.
const getValidSeatsForCoach = useCallback( const getValidSeatsForCoach = useCallback(
(coachData: any): any[] => { (coachData: any): any[] =>
if (!coachData) return []; getValidSeatsForCoachData(coachData, currentSchedule?.selectedSeatClass),
// 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;
},
[currentSchedule?.selectedSeatClass], [currentSchedule?.selectedSeatClass],
); );
@@ -1369,355 +1169,6 @@ export default function SeatsPage() {
} }
}, [seatEligibility, seatEligibleIndices, activePassengerIndex]); }, [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 ( if (
isRoundTrip isRoundTrip
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
@@ -2414,7 +1865,14 @@ export default function SeatsPage() {
No seats in this coach No seats in this coach
</p> </p>
) : ( ) : (
renderCoachSeats(selectedCoachData, isBedCoach) <CoachSeatLayout
coach={selectedCoachData}
isBedCoach={isBedCoach}
seats={validSeats}
isSeatSelected={isSeatSelected}
isSeatAssignedToOther={isSeatAssignedToOther}
onSeatToggle={handleSeatClick}
/>
)} )}
</div> </div>
</div> </div>

View 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>
);
}