Fix auto assign seat

This commit is contained in:
Roba Boru
2026-07-08 21:11:26 +03:00
parent 843b41c652
commit 8897e49972
2 changed files with 185 additions and 104 deletions

View File

@@ -379,36 +379,50 @@ export default function ResultsPage() {
const coachCurrency = "ETB";
const CoachIcon = getCoachIcon(coachType.coachTypeName);
const selectThisCoach = () =>
handleSelectCoachType(
scheduleId,
coachType.coachTypeId,
coachType.coachTypeCode,
coachType.coachTypeName,
coachType.classes?.[0]?.name ||
coachType.coachTypeName,
);
return (
<button
<div
key={coachType.coachId}
onClick={() =>
handleSelectCoachType(
scheduleId,
coachType.coachTypeId,
coachType.coachTypeCode,
coachType.coachTypeName,
coachType.classes?.[0]?.name ||
coachType.coachTypeName,
)
}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
role="button"
tabIndex={0}
onClick={selectThisCoach}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectThisCoach();
}
}}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 cursor-pointer ${
isSelected
? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]"
: "border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
: "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
}`}
style={{
animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both`,
}}
>
{isSelected && (
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
<Check
className="w-4 h-4 text-white"
strokeWidth={3}
/>
</div>
)}
{/* Radio indicator — top-right, persistent (not hover-only) so the
card's selection state is clear on touch too. */}
<span
className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
isSelected
? "border-primary"
: "border-gray-300 dark:border-gray-600 group-hover:border-primary/50"
}`}
>
{isSelected && (
<span className="w-2.5 h-2.5 rounded-full bg-primary animate-scale-in" />
)}
</span>
<div className="flex flex-col">
<div className="flex items-start gap-4 pr-2">
@@ -474,7 +488,7 @@ export default function ResultsPage() {
(cls: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40"
>
<div className="flex items-center gap-2.5">
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
@@ -496,8 +510,37 @@ export default function ResultsPage() {
</div>
</div>
)}
{/* Note — only shown while unselected; once picked, the Continue
button below takes its place. */}
{!isSelected && (
<p className="mt-4 pt-3 border-t border-gray-200/60 dark:border-gray-700/60 text-xs text-gray-400 dark:text-gray-500 italic text-center">
Click to select this coach
</p>
)}
{/* Continue only appears on the card the user has actually picked —
a real nested button (the outer card is a div, not a button, so
this doesn't create invalid/ambiguous nested-button behavior). */}
{isSelected && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSelect(classModal, isOutbound);
}}
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
>
<span>
{isRoundTrip && isOutbound
? "Continue to Return Journey"
: "Continue to Passenger Details"}
</span>
<ArrowRight className="w-4 h-4" />
</button>
)}
</div>
</button>
</div>
);
})}
</div>
@@ -512,33 +555,6 @@ export default function ResultsPage() {
</div>
)}
</div>
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
<div className="w-full max-w-md">
<button
onClick={() => {
if (selectedCoachType) {
handleSelect(classModal, isOutbound);
}
}}
disabled={!selectedCoachType}
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
>
<span>
{isRoundTrip && isOutbound
? "Continue to Return Journey"
: "Continue to Passenger Details"}
</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedCoachType && (
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
Select a coach type to continue
</p>
)}
</div>
</div>
</div>
<style>{`
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}

View File

@@ -315,6 +315,11 @@ export default function SeatsPage() {
// first real pick (no fare-change modal) even though the map already has an entry for
// them — only a click AFTER that (replacing their own real pick) is an actual change.
const restoredIndicesRef = useRef<Set<number>>(new Set());
// Set by handleAutoAssign right before it updates passengerSeatMap, so the effect below
// can proceed straight to Continue once that state update actually lands — calling
// handleContinue() synchronously in the same tick would still see the pre-update
// passengerSeatMap/allSeatsAssigned from this render's closure.
const autoContinueAfterAssignRef = useRef(false);
useEffect(() => {
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
if (restoredLegRef.current === legKey) return;
@@ -689,10 +694,6 @@ export default function SeatsPage() {
() => filteredCoaches.find((c: any) => c.id === selectedCoach),
[filteredCoaches, selectedCoach],
);
const allSeats = useMemo(
() => selectedCoachData?.seats || [],
[selectedCoachData],
);
const getBedPosition = (selectedClass: string): string | null => {
const lowerClass = selectedClass.toLowerCase();
@@ -702,59 +703,87 @@ export default function SeatsPage() {
return null;
};
const validSeats = useMemo(() => {
// If coach has rooms, extract all beds from rooms
if (selectedCoachData?.rooms?.length > 0) {
const allBeds: any[] = [];
selectedCoachData.rooms.forEach((room: any) => {
if (room.beds) {
allBeds.push(...room.beds);
}
});
// 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(
(coachData: any): any[] => {
if (!coachData) return [];
let beds = allBeds.filter((s: any) => {
// 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 =
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed");
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) {
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
}
}
return beds;
}
return seats;
},
[currentSchedule?.selectedSeatClass],
);
// Fallback to old seat structure
let seats = allSeats.filter((s: any) => {
const seatLabel = s.label || s.number || s.seatNumber || "";
return seatLabel && !seatLabel.startsWith("-");
});
const isBedCoach =
selectedCoachData?.isBedCoach === true ||
seats.some((s: any) => s.bedPosition) ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed");
const validSeats = useMemo(
() => getValidSeatsForCoach(selectedCoachData),
[getValidSeatsForCoach, selectedCoachData],
);
if (isBedCoach && currentSchedule?.selectedSeatClass) {
const selectedBedPosition = getBedPosition(
currentSchedule.selectedSeatClass,
);
if (selectedBedPosition) {
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
}
}
return seats;
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
// Every valid seat across every coach of the current type, tagged with which coach it
// belongs to — lets Auto Assign search the whole train (not just the one expanded coach)
// and lets handleContinue resolve a passenger's coach label even when their seat came
// from a coach they never manually expanded.
const allCoachSeats = useMemo(
() =>
filteredCoaches.flatMap((c: any) =>
getValidSeatsForCoach(c).map((s: any) => ({
...s,
_coachId: c.id,
_coachLabel: c.label || c.name || c.coachNumber || c.number || "",
})),
),
[filteredCoaches, getValidSeatsForCoach],
);
// Seats already claimed by any passenger in this journey leg, plus outbound
const assignedSeatIds = useMemo(
@@ -989,12 +1018,15 @@ export default function SeatsPage() {
try {
await ensureLegHold(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
// Look up from allCoachSeats (every coach of this type), not just the currently
// expanded one — Auto Assign can place passengers in a coach that was never
// manually expanded.
const seatData = allCoachSeats?.find((s: any) => s.id === seatIds[i]);
return {
...p,
outboundSeatId: seatIds[i],
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
outboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
outboundCoachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
outboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
outboundBedPosition: seatData?.bedPosition || undefined,
};
@@ -1170,13 +1202,16 @@ export default function SeatsPage() {
try {
await ensureLegHold(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
// See the outbound branch above — resolve from allCoachSeats so a passenger whose
// seat came from a coach Auto Assign picked (but was never manually expanded)
// still gets the right coach label.
const seatData = allCoachSeats?.find((s: any) => s.id === seatIds[i]);
if (isRoundTrip && currentJourneyType === "inbound") {
return {
...p,
inboundSeatId: seatIds[i],
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
inboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
inboundCoachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
inboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
inboundBedPosition: seatData?.bedPosition || undefined,
};
@@ -1185,7 +1220,7 @@ export default function SeatsPage() {
...p,
seatId: seatIds[i],
seatNumber: seatData ? buildSeatLabel(seatData) : '',
coachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
coachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
seatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
bedPosition: seatData?.bedPosition || undefined,
};
@@ -1197,7 +1232,7 @@ export default function SeatsPage() {
if (!isRoundTrip && isPackageBooking && packageId) {
const firstEligibleIdx = seatEligibleIndices[0];
const firstSeatData = firstEligibleIdx != null
? validSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
? allCoachSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
: null;
const berthFare = firstSeatData ? getSeatFare(firstSeatData) : null;
if (berthFare != null) {
@@ -1228,20 +1263,38 @@ export default function SeatsPage() {
router.push("/booking/review");
};
// Fires once the passengerSeatMap update from handleAutoAssign has actually landed (and
// every eligible passenger now has a seat) — proceeds straight to Continue so the user
// doesn't have to click it separately after auto-assigning.
useEffect(() => {
if (autoContinueAfterAssignRef.current && allSeatsAssigned) {
autoContinueAfterAssignRef.current = false;
handleContinue();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passengerSeatMap, allSeatsAssigned]);
const handleAutoAssign = () => {
const unassignedIndices = seatEligibleIndices.filter((i) => !passengerSeatMap[i]);
if (unassignedIndices.length === 0) return;
const availableSeats = (
validSeats?.filter((s: any) => s.status === "AVAILABLE") || []
).filter((s: any) => !assignedSeatIds.has(s.id));
// Search the whole train (every coach of this type), not just whichever one happens to
// be expanded — fill the currently-expanded coach first so the result stays visible
// without switching coaches, then spill over into other coaches only if needed.
const availableAcrossTrain = allCoachSeats
.filter((s: any) => s.status === "AVAILABLE" && !assignedSeatIds.has(s.id))
.sort((a: any, b: any) => {
const aCurrent = a._coachId === selectedCoach ? 0 : 1;
const bCurrent = b._coachId === selectedCoach ? 0 : 1;
return aCurrent - bCurrent;
});
if (availableSeats.length < unassignedIndices.length) {
if (availableAcrossTrain.length < unassignedIndices.length) {
setModalState({
isOpen: true,
title: "Not Enough Seats",
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`,
message: `Only ${availableAcrossTrain.length} seat(s) available across this coach type, but you need ${unassignedIndices.length} more seat(s). Please select another coach type.`,
type: "warning",
onConfirm: undefined,
showCancel: false,
@@ -1251,11 +1304,21 @@ export default function SeatsPage() {
}
const next = { ...passengerSeatMap };
let lastAssignedCoachId: string | null = null;
unassignedIndices.forEach((passengerIndex, offset) => {
next[passengerIndex] = availableSeats[offset].id;
const seat = availableAcrossTrain[offset];
next[passengerIndex] = seat.id;
lastAssignedCoachId = seat._coachId;
});
// Proceed straight to Continue once this update lands — no separate click needed.
autoContinueAfterAssignRef.current = true;
setPassengerSeatMap(next);
setActivePassengerIndex(seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0);
// Bring whichever coach the last passenger landed in into view, since seats may now
// span coaches beyond the one that was expanded when Auto Assign was clicked.
if (lastAssignedCoachId && lastAssignedCoachId !== selectedCoach) {
setSelectedCoach(lastAssignedCoachId);
}
};
const handleBackToPassengers = () => {
@@ -1857,8 +1920,10 @@ export default function SeatsPage() {
}
const assignedSeatId = passengerSeatMap[i];
// allCoachSeats (not validSeats) so a seat Auto Assign placed in a coach the user
// never manually expanded still shows correctly here.
const assignedSeat = assignedSeatId
? validSeats?.find((s: any) => s.id === assignedSeatId)
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
const seatFare = assignedSeat