|
|
|
|
@@ -4,7 +4,6 @@ export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { useBookingStore } from "@/lib/booking-store";
|
|
|
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
|
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
|
|
|
import { apiClient } from "@/lib/api-client";
|
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
|
|
|
|
|
@@ -181,7 +180,6 @@ export default function SeatsPage() {
|
|
|
|
|
packageDepartureStationName,
|
|
|
|
|
setPackageContext,
|
|
|
|
|
} = useBookingStore();
|
|
|
|
|
const { isAuthenticated } = useAuthStore();
|
|
|
|
|
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
|
|
|
|
|
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
|
|
|
|
|
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
|
|
|
|
@@ -209,6 +207,10 @@ export default function SeatsPage() {
|
|
|
|
|
confirmText: "OK",
|
|
|
|
|
});
|
|
|
|
|
const [autoAssigningReturn, setAutoAssigningReturn] = useState(false);
|
|
|
|
|
// Mobile summary bottom-sheet starts collapsed to a slim bar (badge + Continue button) so
|
|
|
|
|
// it doesn't cover the seat map — the full passenger list/progress bar only shows once the
|
|
|
|
|
// user explicitly expands it.
|
|
|
|
|
const [mobileSummaryExpanded, setMobileSummaryExpanded] = useState(false);
|
|
|
|
|
|
|
|
|
|
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
|
|
|
|
|
const isPackageBooking = !!packageName;
|
|
|
|
|
@@ -946,9 +948,9 @@ export default function SeatsPage() {
|
|
|
|
|
// already held (valid, unexpired), skip the API call entirely and reuse that hold — this
|
|
|
|
|
// is what stops "back, then Continue again" from stacking up a second hold on the same
|
|
|
|
|
// seats. If the user genuinely picked different seats than what was previously held,
|
|
|
|
|
// best-effort release the stale hold first (authenticated sessions only — the release
|
|
|
|
|
// endpoint requires a login) before holding the new selection, so at most one hold for
|
|
|
|
|
// this leg is ever active at a time.
|
|
|
|
|
// release the stale hold first (best-effort — an already-expired/released hold shouldn't
|
|
|
|
|
// block picking the new seat(s)) before holding the new selection, so at most one hold for
|
|
|
|
|
// this leg is ever active at a time — for both guest and authenticated sessions.
|
|
|
|
|
const ensureLegHold = async (seatIdsForHold: string[]) => {
|
|
|
|
|
const selectionMatchesExistingHold =
|
|
|
|
|
isCurrentLegHoldValid &&
|
|
|
|
|
@@ -958,12 +960,17 @@ export default function SeatsPage() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isCurrentLegHoldValid && isAuthenticated && currentLegHoldId) {
|
|
|
|
|
if (isCurrentLegHoldValid && currentLegHoldId) {
|
|
|
|
|
try {
|
|
|
|
|
await apiClient.delete(`/seats/hold/${currentLegHoldId}`);
|
|
|
|
|
// Routed through the portal's own server-side proxy (not called on the backend
|
|
|
|
|
// directly) so the release capability never appears in client-side network calls.
|
|
|
|
|
await fetch("/api/seats/release-hold", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ holdId: currentLegHoldId }),
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort — an expired/already-released hold, or a guest session that can't
|
|
|
|
|
// call this endpoint, shouldn't block picking the new seat(s).
|
|
|
|
|
// Best-effort — an expired/already-released hold shouldn't block picking the new seat(s).
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1786,23 +1793,29 @@ export default function SeatsPage() {
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Summary card content — shared between sidebar and mobile modal
|
|
|
|
|
const SummaryContent = () => (
|
|
|
|
|
// Summary content, split into pieces so the mobile bottom-sheet can show a compact
|
|
|
|
|
// header + always-reachable Continue button by default, and only reveal the full
|
|
|
|
|
// passenger list (SummaryDetails) when the user explicitly expands it — otherwise it
|
|
|
|
|
// permanently covers most of the seat map on small screens.
|
|
|
|
|
const SummaryHeader = () => (
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
|
|
|
|
Selection Summary
|
|
|
|
|
</h3>
|
|
|
|
|
<span
|
|
|
|
|
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
|
|
|
|
allSeatsAssigned
|
|
|
|
|
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
|
|
|
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{assignedCount}/{seatEligibleIndices.length} selected
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const SummaryDetails = () => (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
|
|
|
|
Selection Summary
|
|
|
|
|
</h3>
|
|
|
|
|
<span
|
|
|
|
|
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
|
|
|
|
allSeatsAssigned
|
|
|
|
|
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
|
|
|
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{assignedCount}/{seatEligibleIndices.length} selected
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
|
|
|
{allSeatsAssigned
|
|
|
|
|
? "All seats selected — ready to continue"
|
|
|
|
|
@@ -1910,27 +1923,49 @@ export default function SeatsPage() {
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleContinue}
|
|
|
|
|
disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
|
|
|
|
|
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
|
|
|
|
>
|
|
|
|
|
{autoAssigningReturn
|
|
|
|
|
? "Assigning return seats..."
|
|
|
|
|
: holdMutation.isPending
|
|
|
|
|
? "Holding seats..."
|
|
|
|
|
: isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
|
|
|
|
|
? "Continue to Return Seats"
|
|
|
|
|
: "Continue"}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleAutoAssign}
|
|
|
|
|
disabled={holdMutation.isPending || allSeatsAssigned}
|
|
|
|
|
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
|
|
|
|
>
|
|
|
|
|
Auto Assign Seats
|
|
|
|
|
</button>
|
|
|
|
|
const SummaryContinueButton = () => (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleContinue}
|
|
|
|
|
disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
|
|
|
|
|
className="w-full py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
|
|
|
|
>
|
|
|
|
|
{autoAssigningReturn
|
|
|
|
|
? "Assigning return seats..."
|
|
|
|
|
: holdMutation.isPending
|
|
|
|
|
? "Holding seats..."
|
|
|
|
|
: isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
|
|
|
|
|
? "Continue to Return Seats"
|
|
|
|
|
: "Continue"}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const SummaryAutoAssignButton = () => (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleAutoAssign}
|
|
|
|
|
disabled={holdMutation.isPending || allSeatsAssigned}
|
|
|
|
|
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
|
|
|
|
>
|
|
|
|
|
Auto Assign Seats
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const SummaryActions = () => (
|
|
|
|
|
<>
|
|
|
|
|
<SummaryContinueButton />
|
|
|
|
|
<SummaryAutoAssignButton />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Full summary card — used as-is in the desktop sidebar, which has room to show
|
|
|
|
|
// everything at once.
|
|
|
|
|
const SummaryContent = () => (
|
|
|
|
|
<>
|
|
|
|
|
<SummaryHeader />
|
|
|
|
|
<SummaryDetails />
|
|
|
|
|
<SummaryActions />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
@@ -1998,15 +2033,41 @@ export default function SeatsPage() {
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Mobile summary bottom-sheet — always visible so the active passenger is clear */}
|
|
|
|
|
{/* Mobile summary bottom-sheet — collapsed to a single slim row by default so it
|
|
|
|
|
barely dents the seat map; tap it to expand the full passenger list + auto-assign.
|
|
|
|
|
The Continue button always stays visible either way. */}
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5 max-h-[70vh] overflow-y-auto"
|
|
|
|
|
className={`fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl overflow-hidden flex flex-col ${
|
|
|
|
|
mobileSummaryExpanded ? "max-h-[70vh]" : ""
|
|
|
|
|
}`}
|
|
|
|
|
style={{
|
|
|
|
|
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
|
|
|
|
|
<SummaryContent />
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setMobileSummaryExpanded((v) => !v)}
|
|
|
|
|
className="w-full flex items-center justify-between px-5 py-2.5 flex-shrink-0"
|
|
|
|
|
aria-label={mobileSummaryExpanded ? "Collapse seat selection summary" : "Expand seat selection summary"}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
|
|
|
{assignedCount}/{seatEligibleIndices.length} seats selected
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={`w-4 h-4 text-gray-400 transition-transform ${mobileSummaryExpanded ? "rotate-180" : ""}`}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{mobileSummaryExpanded && (
|
|
|
|
|
<div className="px-5 overflow-y-auto flex-1 min-h-0">
|
|
|
|
|
<SummaryDetails />
|
|
|
|
|
<SummaryAutoAssignButton />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="px-5 pb-4 pt-2 flex-shrink-0 border-t border-gray-100 dark:border-gray-800">
|
|
|
|
|
<SummaryContinueButton />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
|
|
|
|
|
|
|
|
|
@@ -2326,8 +2387,9 @@ export default function SeatsPage() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
|
|
|
|
|
<div className="h-48 lg:hidden" />
|
|
|
|
|
{/* Mobile spacer so the bottom-sheet doesn't cover the last seat — sized to
|
|
|
|
|
match the sheet's current (collapsed/expanded) height. */}
|
|
|
|
|
<div className={`lg:hidden ${mobileSummaryExpanded ? "h-[70vh]" : "h-24"}`} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|