mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: (reschedule) use the home-page station dropdown and available-date calendar in the portal reschedule form
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
215
apps/edr-passenger-web/portal/src/components/StationDropdown.tsx
Normal file
215
apps/edr-passenger-web/portal/src/components/StationDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user