feat: (reschedule) use the home-page station dropdown and available-date calendar in the portal reschedule form

This commit is contained in:
Abubeker Yasin
2026-08-21 15:30:50 +03:00
parent 80a7a2f702
commit 9843b64989
3 changed files with 367 additions and 178 deletions

View File

@@ -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<string[]>([]);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [dateNotice, setDateNotice] = useState<string | null>(null);
const [recentStationIds, setRecentStationIds] = useState<string[]>(
readRecentStationIds,
);
const saveRecent = (id: string) =>
setRecentStationIds((prev) => pushRecentStation(id, prev));
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
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<AvailableDatesResponse>({
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<string>();
// 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<any[]>({
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 (
<Shell>
@@ -241,17 +326,67 @@ function ReschedulePageContent() {
<>
{/* Step 1: route + date */}
<div className="grid md:grid-cols-4 gap-3 mb-4">
<select className="input" value={originId} disabled={routeLocked} onChange={(e) => setOriginId(e.target.value)}>
{stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<select className="input" value={destinationId} disabled={routeLocked} onChange={(e) => setDestinationId(e.target.value)}>
{stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<ModernDatePicker value={date} onChange={setDate} minDate={new Date()} />
<StationDropdown
stations={stations}
value={originId}
excludeId={destinationId}
placeholder="From"
recentIds={recentStationIds}
disabled={routeLocked}
onSelect={(s) => {
setOriginId(s.id);
if (s.id) saveRecent(s.id);
setDateNotice(null);
setSchedule(null);
setSeatIds([]);
setSearched(null);
}}
/>
<StationDropdown
stations={stations}
value={destinationId}
excludeId={originId}
placeholder="To"
recentIds={recentStationIds}
disabled={routeLocked}
onSelect={(s) => {
setDestinationId(s.id);
if (s.id) saveRecent(s.id);
setDateNotice(null);
setSchedule(null);
setSeatIds([]);
setSearched(null);
}}
/>
<ModernDatePicker
value={date}
onChange={(d) => {
setDateNotice(null);
setDate(d);
}}
minDate={new Date()}
maxDate={originId && destinationId ? maxSearchDate : undefined}
disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="New date"
/>
<button className="btn-primary" disabled={!canSearch || searching} onClick={() => { setSchedule(null); setSeatIds([]); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
{searching ? "Searching..." : "Find trains"}
</button>
</div>
{routeLocked && (
<p className="text-xs text-gray-500 dark:text-gray-400 -mt-2 mb-4">
Your fare class does not permit changing stations only the date and train.
</p>
)}
{noRouteForPair && (
<p className="text-xs text-amber-600 dark:text-amber-400 -mt-2 mb-4">
No route connects these stations pick a different destination.
</p>
)}
{dateNotice && !noRouteForPair && (
<p className="text-xs text-amber-600 dark:text-amber-400 -mt-2 mb-4">{dateNotice}</p>
)}
{/* Step 2: schedule */}
{searched && !searching && schedules.length === 0 && <p className="text-sm text-gray-500 mb-4">No trains on that day.</p>}

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={ref} className="relative">
<div
className={`relative flex items-center border-2 rounded-xl transition-all duration-200 bg-white dark:bg-gray-800 ${
error
? "border-red-400"
: open
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<MapPin className="absolute left-3.5 w-4 h-4 text-primary flex-shrink-0" />
<input
ref={inputRef}
value={displayValue}
onChange={(e) => {
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 && (
<button
type="button"
onClick={() => {
onSelect({ id: "", name: "", code: "", country: "" });
setQuery("");
}}
className="absolute right-3 p-0.5"
>
<X className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{open && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-96 overflow-y-auto overflow-x-hidden scrollbar-hide">
{!query && recentIds.length > 0 && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
Recent
</p>
{recentIds
.map((id) => stations.find((s) => s.id === id))
.filter(Boolean)
.map((s) => (
<button
key={s!.id}
type="button"
onMouseDown={() => {
onSelect(s!);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
>
<Clock className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<span className="text-sm text-gray-800 dark:text-gray-200">
{s!.name}
</span>
</button>
))}
<div className="border-t border-gray-100 dark:border-gray-700 mt-1 mb-1" />
</div>
)}
{filtered.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">
No stations found
</p>
) : (
filtered.map((s) => (
<button
key={s.id}
type="button"
onMouseDown={() => {
onSelect(s);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
>
<MapPin className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{s.name}
</span>
{s.code && (
<span className="text-xs text-gray-400 ml-1.5">
{s.code}
</span>
)}
</div>
</button>
))
)}
</div>
)}
</div>
);
}
// ─── 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<string[]>(() => {
try {
return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
} catch {
return [];
}
});
const [recentStationIds, setRecentStationIds] = useState<string[]>(
readRecentStationIds,
);
const passengerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<HTMLDivElement>(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 = () => {

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={ref} className="relative">
<div
className={`relative flex items-center border-2 rounded-xl transition-all duration-200 ${
disabled
? "bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700"
: "bg-white dark:bg-gray-800"
} ${
error
? "border-red-400"
: disabled
? ""
: open
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<MapPin
className={`absolute left-3.5 w-4 h-4 flex-shrink-0 ${disabled ? "text-gray-400" : "text-primary"}`}
/>
<input
ref={inputRef}
value={displayValue}
readOnly={disabled}
disabled={disabled}
onChange={(e) => {
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 && (
<button
type="button"
onClick={() => {
onSelect({ id: "", name: "", code: "", country: "" });
setQuery("");
}}
className="absolute right-3 p-0.5"
>
<X className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{open && !disabled && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-96 overflow-y-auto overflow-x-hidden scrollbar-hide">
{!query && recentIds.length > 0 && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
Recent
</p>
{recentIds
.map((id) => stations.find((s) => s.id === id))
.filter(Boolean)
.map((s) => (
<button
key={s!.id}
type="button"
onMouseDown={() => {
onSelect(s!);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
>
<Clock className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<span className="text-sm text-gray-800 dark:text-gray-200">
{s!.name}
</span>
</button>
))}
<div className="border-t border-gray-100 dark:border-gray-700 mt-1 mb-1" />
</div>
)}
{filtered.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">
No stations found
</p>
) : (
filtered.map((s) => (
<button
key={s.id}
type="button"
onMouseDown={() => {
onSelect(s);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
>
<MapPin className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{s.name}
</span>
{s.code && (
<span className="text-xs text-gray-400 ml-1.5">
{s.code}
</span>
)}
</div>
</button>
))
)}
</div>
)}
</div>
);
}