diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 85cdc5786..392791806 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -7,9 +7,24 @@ import { format } from "date-fns"; import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api-client"; import ModernDatePicker from "@/components/ModernDatePicker"; +import StationDropdown, { + pushRecentStation, + readRecentStationIds, +} from "@/components/StationDropdown"; import { formatTime } from "@/utils/format"; +import { Station } from "@/types"; -type Station = { id: string; name: string; code?: string }; +// Same horizon the search widget uses — /search/available-dates is server-clamped to 90 days, +// so the picker's maxDate has to match or unchecked future months render as pickable again. +const AVAILABLE_DATES_RANGE_DAYS = 90; + +const toDateStr = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +interface AvailableDatesResponse { + routeExists: boolean; + dates: { date: string; available: boolean }[]; +} type LegOption = { leg: number; @@ -69,6 +84,12 @@ function ReschedulePageContent() { const [seatIds, setSeatIds] = useState([]); const [done, setDone] = useState<{ status: string } | null>(null); const [error, setError] = useState(null); + const [dateNotice, setDateNotice] = useState(null); + const [recentStationIds, setRecentStationIds] = useState( + readRecentStationIds, + ); + const saveRecent = (id: string) => + setRecentStationIds((prev) => pushRecentStation(id, prev)); const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ queryKey: ["reschedule-options", ref], @@ -95,6 +116,69 @@ function ReschedulePageContent() { const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY"; + // Which dates actually have a bookable train for the chosen From/To. Same endpoint and same + // shape the home-page search widget uses, so the reschedule calendar greys out the same days + // rather than letting someone pick a date that can only come back empty. + const { data: availableDates } = useQuery({ + queryKey: ["available-dates", originId, destinationId], + queryFn: async () => { + const from = new Date(); + const to = new Date(); + to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return (await apiClient.get("/search/available-dates", { + params: { + originStationId: originId, + destinationStationId: destinationId, + from: toDateStr(from), + to: toDateStr(to), + }, + })) as AvailableDatesResponse; + }, + enabled: !!originId && !!destinationId && originId !== destinationId, + staleTime: 5 * 60 * 1000, + }); + + const disabledDates = useMemo(() => { + const set = new Set(); + // routeExists === false is handled by disabling the control outright (noRouteForPair): + // the server returns an empty `dates` array in that case anyway. + if (!availableDates?.routeExists) return set; + for (const d of availableDates.dates) if (!d.available) set.add(d.date); + return set; + }, [availableDates]); + + const noRouteForPair = + !!originId && !!destinationId && availableDates?.routeExists === false; + + const maxSearchDate = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return d; + }, []); + + // If the picked date turns out to have no train (stations changed, or the availability query + // just resolved), drop it and say why — rather than letting "Find trains" return nothing. + useEffect(() => { + if (date && disabledDates.has(toDateStr(date))) { + setDate(undefined); + setSearched(null); + setSchedule(null); + setSeatIds([]); + setDateNotice("No trains run this route on that date — please pick another."); + } + }, [date, disabledDates]); + + // Losing the route invalidates any date already chosen, so nothing stale can be submitted + // from behind a now-disabled control. + useEffect(() => { + if (noRouteForPair && date) { + setDate(undefined); + setSearched(null); + setSchedule(null); + setSeatIds([]); + } + }, [noRouteForPair, date]); + const { data: schedules = [], isFetching: searching } = useQuery({ queryKey: ["reschedule-search", searched], queryFn: async () => { @@ -199,7 +283,8 @@ function ReschedulePageContent() { } const routeLocked = !leg.policy?.routeChangeAllowed; - const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId; + const canSearch = + !!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair; return ( @@ -241,17 +326,67 @@ function ReschedulePageContent() { <> {/* Step 1: route + date */}
- - - + { + setOriginId(s.id); + if (s.id) saveRecent(s.id); + setDateNotice(null); + setSchedule(null); + setSeatIds([]); + setSearched(null); + }} + /> + { + setDestinationId(s.id); + if (s.id) saveRecent(s.id); + setDateNotice(null); + setSchedule(null); + setSeatIds([]); + setSearched(null); + }} + /> + { + setDateNotice(null); + setDate(d); + }} + minDate={new Date()} + maxDate={originId && destinationId ? maxSearchDate : undefined} + disabledDates={disabledDates} + disabled={noRouteForPair} + placeholder="New date" + />
+ {routeLocked && ( +

+ Your fare class does not permit changing stations — only the date and train. +

+ )} + {noRouteForPair && ( +

+ No route connects these stations — pick a different destination. +

+ )} + {dateNotice && !noRouteForPair && ( +

{dateNotice}

+ )} {/* Step 2: schedule */} {searched && !searching && schedules.length === 0 &&

No trains on that day.

} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index bb4e2cf12..3b6a17bb8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -23,6 +23,10 @@ import { } from "lucide-react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import ModernDatePicker from "@/components/ModernDatePicker"; +import StationDropdown, { + pushRecentStation, + readRecentStationIds, +} from "@/components/StationDropdown"; const AVAILABLE_DATES_RANGE_DAYS = 90; @@ -393,163 +397,6 @@ function PassengerModal({ ); } -// ─── Station Autocomplete (Desktop dropdown) ────────────────────────────────── -function StationDropdown({ - stations, - value, - excludeId, - placeholder, - onSelect, - error, - recentIds, - onOpen, -}: { - stations: Station[]; - value: string; - excludeId?: string; - placeholder: string; - onSelect: (s: Station) => void; - error?: string; - recentIds: string[]; - onOpen?: () => void; -}) { - const [query, setQuery] = useState(""); - const [open, setOpen] = useState(false); - const ref = useRef(null); - const inputRef = useRef(null); - const selectedStation = stations.find((s) => s.id === value); - - useEffect(() => { - if (selectedStation && !open) setQuery(""); - }, [selectedStation, open]); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = query.trim() - ? stations.filter( - (s) => - s.id !== excludeId && - (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())), - ) - : stations.filter((s) => s.id !== excludeId).slice(0, 20); - - const displayValue = open ? query : (selectedStation?.name ?? ""); - - return ( -
-
- - { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => { - setQuery(""); - setOpen(true); - onOpen?.(); - }} - placeholder={placeholder} - className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" - /> - {value && ( - - )} -
- - {open && ( -
- {!query && recentIds.length > 0 && ( -
-

- Recent -

- {recentIds - .map((id) => stations.find((s) => s.id === id)) - .filter(Boolean) - .map((s) => ( - - ))} -
-
- )} - {filtered.length === 0 ? ( -

- No stations found -

- ) : ( - filtered.map((s) => ( - - )) - )} -
- )} -
- ); -} - // ─── Main Page ──────────────────────────────────────────────────────────────── export default function SearchPage() { const router = useRouter(); @@ -581,13 +428,9 @@ export default function SearchPage() { useEffect(() => { router.prefetch("/booking/results"); }, [router]); - const [recentStationIds, setRecentStationIds] = useState(() => { - try { - return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); - } catch { - return []; - } - }); + const [recentStationIds, setRecentStationIds] = useState( + readRecentStationIds, + ); const passengerRef = useRef(null); const widgetRef = useRef(null); @@ -847,11 +690,7 @@ export default function SearchPage() { }, [noReturnRouteForPair, returnDate, setValue, clearErrors]); const saveRecent = useCallback((id: string) => { - setRecentStationIds((prev) => { - const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); - localStorage.setItem("edr_recent_stations", JSON.stringify(next)); - return next; - }); + setRecentStationIds((prev) => pushRecentStation(id, prev)); }, []); const handleSwap = () => { diff --git a/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx new file mode 100644 index 000000000..8b0fb0073 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Clock, MapPin, X } from "lucide-react"; +import { Station } from "@/types"; + +// Shared by the search widget (home page) and the reschedule flow so both pick stations the +// same way. Recents live under one localStorage key, so a station picked on the home page is +// still offered as "Recent" when rescheduling. +export const RECENT_STATIONS_KEY = "edr_recent_stations"; +export const MAX_RECENT_STATIONS = 5; + +export function readRecentStationIds(): string[] { + try { + const raw = JSON.parse(localStorage.getItem(RECENT_STATIONS_KEY) || "[]"); + return Array.isArray(raw) ? raw : []; + } catch { + return []; + } +} + +/** Prepends `id`, de-duplicates, caps the list, persists it, and returns the new list. */ +export function pushRecentStation(id: string, prev: string[]): string[] { + const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT_STATIONS); + try { + localStorage.setItem(RECENT_STATIONS_KEY, JSON.stringify(next)); + } catch { + /* private mode / storage disabled — recents are a convenience, never a requirement */ + } + return next; +} + +// ─── Station Autocomplete ───────────────────────────────────────────────────── +export default function StationDropdown({ + stations, + value, + excludeId, + placeholder, + onSelect, + error, + recentIds, + onOpen, + disabled = false, +}: { + stations: Station[]; + value: string; + excludeId?: string; + placeholder: string; + onSelect: (s: Station) => void; + error?: string; + recentIds: string[]; + onOpen?: () => void; + /** + * Read-only: shows the selection but refuses to open. Used where the route is fixed — + * a fare class whose policy sets `routeChangeAllowed: false` cannot change stations, and + * a dropdown that opens only to reject the pick is worse than one that plainly can't. + */ + disabled?: boolean; +}) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const inputRef = useRef(null); + const selectedStation = stations.find((s) => s.id === value); + + useEffect(() => { + if (selectedStation && !open) setQuery(""); + }, [selectedStation, open]); + + // Close if the control is disabled while open (e.g. switching to a route-locked leg). + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())), + ) + : stations.filter((s) => s.id !== excludeId).slice(0, 20); + + const displayValue = open ? query : (selectedStation?.name ?? ""); + + return ( +
+
+ + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + if (disabled) return; + setQuery(""); + setOpen(true); + onOpen?.(); + }} + placeholder={placeholder} + className={`w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm placeholder-gray-400 ${ + disabled + ? "text-gray-500 dark:text-gray-400 cursor-not-allowed" + : "text-gray-900 dark:text-white" + }`} + /> + {value && !disabled && ( + + )} +
+ + {open && !disabled && ( +
+ {!query && recentIds.length > 0 && ( +
+

+ Recent +

+ {recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) + .map((s) => ( + + ))} +
+
+ )} + {filtered.length === 0 ? ( +

+ No stations found +

+ ) : ( + filtered.map((s) => ( + + )) + )} +
+ )} +
+ ); +}