diff --git a/src/components/home-client.tsx b/src/components/home-client.tsx index cb885dd..7abc59e 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -559,6 +559,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale onCorridorKmChange={setCorridorKm} initialRoute={initialRoute} selectedFuel={selectedFuel} + userLocation={userLocation} /> diff --git a/src/components/search/bottom-sheet.test.tsx b/src/components/search/bottom-sheet.test.tsx new file mode 100644 index 0000000..d88b23e --- /dev/null +++ b/src/components/search/bottom-sheet.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BottomSheet, type SheetSnap } from "./bottom-sheet"; + +vi.mock("@/lib/i18n", () => ({ useI18n: () => ({ t: (k: string) => k }) })); + +// innerHeight = 800 → sheetHeight = 800 * 0.9 = 720. +// full → translateY(0) +// half → 720 - 800*0.5 = 320 +// peek → 720 - 116 = 604 +const VH = 800; +const SHEET_H = VH * 0.9; +const T = { full: 0, half: SHEET_H - VH * 0.5, peek: SHEET_H - 116 }; + +function renderSheet(snap: SheetSnap, onSnapChange = vi.fn()) { + const utils = render( + +
body
+
, + ); + const region = screen.getByRole("region", { name: "sheet.routeAndStations" }); + const handle = screen.getByRole("button", { name: "sheet.resize" }); + return { ...utils, region, handle, onSnapChange }; +} + +describe("BottomSheet", () => { + beforeEach(() => { + Object.defineProperty(window, "innerHeight", { value: VH, configurable: true, writable: true }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("translates to the correct Y for each snap point", () => { + for (const s of ["full", "half", "peek"] as const) { + const { region, unmount } = renderSheet(s); + expect(region.style.transform).toBe(`translateY(${T[s]}px)`); + unmount(); + } + }); + + it("tap (no movement) cycles peek → half → full → peek", () => { + // peek → half + let r = renderSheet("peek"); + fireEvent.pointerDown(r.handle, { clientY: 700, pointerId: 1 }); + fireEvent.pointerUp(r.handle, { clientY: 700, pointerId: 1 }); + expect(r.onSnapChange).toHaveBeenCalledWith("half"); + r.unmount(); + // half → full + r = renderSheet("half"); + fireEvent.pointerDown(r.handle, { clientY: 400, pointerId: 1 }); + fireEvent.pointerUp(r.handle, { clientY: 400, pointerId: 1 }); + expect(r.onSnapChange).toHaveBeenCalledWith("full"); + r.unmount(); + // full → peek (wrap) + r = renderSheet("full"); + fireEvent.pointerDown(r.handle, { clientY: 100, pointerId: 1 }); + fireEvent.pointerUp(r.handle, { clientY: 100, pointerId: 1 }); + expect(r.onSnapChange).toHaveBeenCalledWith("peek"); + }); + + it("dragging down from full snaps to the nearest lower point", () => { + const { handle, onSnapChange } = renderSheet("full"); + // Drag down ~340px: lands near half (320), far from peek (604) and full (0). + fireEvent.pointerDown(handle, { clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); // delta +340 + fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 }); + expect(onSnapChange).toHaveBeenCalledWith("half"); + }); + + it("dragging up from peek snaps toward full", () => { + const { handle, onSnapChange } = renderSheet("peek"); + // From peek translate 604, drag up 600px → ~4, nearest is full (0). + fireEvent.pointerDown(handle, { clientY: 700, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 100, pointerId: 1 }); // delta -600 + fireEvent.pointerUp(handle, { clientY: 100, pointerId: 1 }); + expect(onSnapChange).toHaveBeenCalledWith("full"); + }); + + it("a sub-slop move (<=4px) is treated as a tap, not a drag", () => { + const { handle, onSnapChange } = renderSheet("half"); + fireEvent.pointerDown(handle, { clientY: 400, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 403, pointerId: 1 }); // 3px → tap + fireEvent.pointerUp(handle, { clientY: 403, pointerId: 1 }); + expect(onSnapChange).toHaveBeenCalledWith("full"); // half → full cycle + }); + + it("pointercancel restores without cycling the snap", () => { + const { handle, onSnapChange } = renderSheet("half"); + fireEvent.pointerDown(handle, { clientY: 400, pointerId: 1 }); + fireEvent.pointerCancel(handle, { clientY: 400, pointerId: 1 }); + expect(onSnapChange).not.toHaveBeenCalled(); + }); + + it("keyboard ArrowUp/ArrowDown/Enter resize the sheet", () => { + const { handle, onSnapChange } = renderSheet("peek"); + fireEvent.keyDown(handle, { key: "ArrowUp" }); + expect(onSnapChange).toHaveBeenCalledWith("half"); // peek → half + onSnapChange.mockClear(); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(onSnapChange).toHaveBeenCalledWith("half"); // cycle peek → half + }); + + it("exposes aria-expanded reflecting the snap state", () => { + const peek = renderSheet("peek"); + expect(peek.handle).toHaveAttribute("aria-expanded", "false"); + peek.unmount(); + const half = renderSheet("half"); + expect(half.handle).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/src/components/search/bottom-sheet.tsx b/src/components/search/bottom-sheet.tsx new file mode 100644 index 0000000..e8f0c7a --- /dev/null +++ b/src/components/search/bottom-sheet.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useI18n } from "@/lib/i18n"; + +export type SheetSnap = "peek" | "half" | "full"; + +interface BottomSheetProps { + /** Sheet content. The header (handle + optional summary) is rendered by the sheet. */ + children: React.ReactNode; + /** Always-visible summary shown next to the drag handle (e.g. "6.4 km · 8 min"). */ + summary?: React.ReactNode; + /** Controlled snap point. */ + snap: SheetSnap; + onSnapChange: (snap: SheetSnap) => void; +} + +// Snap positions as a fraction of the viewport height that the sheet OCCUPIES +// (so larger = taller sheet). "peek" reveals only PEEK_HEIGHT px (the header). +const HALF_FRACTION = 0.5; +const FULL_FRACTION = 0.9; +// Visible peek strip = drag handle + the one/two-line summary. Must track the +// rendered header height; kept as a constant since the header is fixed-size. +const PEEK_HEIGHT = 116; +// A pointer move under this many px counts as a tap (cycle), not a drag (snap). +const TAP_SLOP = 4; + +/** + * Mobile bottom sheet with drag-to-snap (peek / half / full). The sheet is + * fixed to the bottom of the viewport over a full-height map; dragging the + * handle (or anywhere in the header) snaps between the three heights. Tapping + * the handle cycles peek → half → full → peek, and the handle is a real button + * so keyboard users can resize it (↑/↓/Enter/Space). + * + * Rendered ONLY on mobile (the parent gates on a media query); on desktop the + * panel keeps its original side-card layout, so this component never mounts + * there and the desktop tests are unaffected. + */ +export function BottomSheet({ children, summary, snap, onSnapChange }: BottomSheetProps) { + const { t } = useI18n(); + // Measure the viewport lazily so the first client paint already uses a real + // height (avoids a full-open flash and a dead drag while vh is still 0). + const [vh, setVh] = useState(() => (typeof window !== "undefined" ? window.innerHeight : 0)); + // Live drag state lives in a ref (read synchronously at pointerup, immune to + // React's async state batching) plus a mirror in state to drive the render. + const dragRef = useRef<{ + startY: number; + startTranslate: number; + pointerId: number; + moved: boolean; + maxDown: number; // clamp ceiling snapshotted at pointerdown — stable mid-gesture + translate: number; // latest live translate, read in settle() + } | null>(null); + const [dragTranslate, setDragTranslate] = useState(null); + + useEffect(() => { + const measure = () => { + // Ignore resizes mid-drag so the gesture keeps one consistent basis. + if (dragRef.current) return; + setVh(window.innerHeight); + }; + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, []); + + // translateY (px from fully-open at top) for a given snap point. The sheet's + // own height is FULL_FRACTION of vh; we slide it down to reveal less. + const sheetHeight = vh * FULL_FRACTION; + const translateForSnap = useCallback( + (s: SheetSnap): number => { + if (vh === 0) return 0; + if (s === "full") return 0; + if (s === "half") return sheetHeight - vh * HALF_FRACTION; + // peek: only PEEK_HEIGHT px visible at the bottom + return sheetHeight - PEEK_HEIGHT; + }, + [vh, sheetHeight], + ); + + const restingTranslate = translateForSnap(snap); + const currentTranslate = dragTranslate ?? restingTranslate; + + const cycleSnap = useCallback( + () => onSnapChange(snap === "peek" ? "half" : snap === "half" ? "full" : "peek"), + [snap, onSnapChange], + ); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + // Don't start a drag before the viewport is measured (vh===0 would pin + // the sheet open). The tap fallback / keyboard still work. + if (vh === 0) return; + dragRef.current = { + startY: e.clientY, + startTranslate: restingTranslate, + pointerId: e.pointerId, + moved: false, + maxDown: sheetHeight - PEEK_HEIGHT, + translate: restingTranslate, + }; + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + }, + [vh, restingTranslate, sheetHeight], + ); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const delta = e.clientY - d.startY; + if (Math.abs(delta) > TAP_SLOP) d.moved = true; + // Clamp between fully-open (0) and peek, using the basis snapshotted at down. + const next = Math.max(0, Math.min(d.maxDown, d.startTranslate + delta)); + d.translate = next; + setDragTranslate(next); + }, []); + + const settle = useCallback( + (e: React.PointerEvent) => { + const d = dragRef.current; + dragRef.current = null; + if (!d) return; + (e.currentTarget as HTMLElement).releasePointerCapture?.(d.pointerId); + setDragTranslate(null); + + // A browser-stolen gesture (pointercancel) should restore, never cycle. + if (e.type === "pointercancel") return; + + // Tap (no real movement) → cycle to the next snap point. + if (!d.moved) { + cycleSnap(); + return; + } + + // Snap to whichever resting position is nearest the released translate + // (read from the ref, so a fast flick's final position isn't lost). + const released = d.translate; + const candidates: SheetSnap[] = ["full", "half", "peek"]; + let best: SheetSnap = "peek"; + let bestDist = Infinity; + for (const c of candidates) { + const dist = Math.abs(translateForSnap(c) - released); + if (dist < bestDist) { bestDist = dist; best = c; } + } + onSnapChange(best); + }, + [translateForSnap, onSnapChange, cycleSnap], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowUp") { e.preventDefault(); onSnapChange(snap === "peek" ? "half" : "full"); } + else if (e.key === "ArrowDown") { e.preventDefault(); onSnapChange(snap === "full" ? "half" : "peek"); } + else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); cycleSnap(); } + }, + [snap, onSnapChange, cycleSnap], + ); + + return ( +
+ {/* Drag handle — a real button so it's keyboard/AT operable. `touch-none` + lives ONLY here (not the container) so the body below can still scroll + on touch. Pointer events drive drag/tap; keys drive snap for keyboards. */} + + {/* Scrollable body — the station list scrolls here when the sheet is tall. + pan-y lets vertical touch-scroll work even with the dragging ancestor. */} +
+ {children} +
+
+ ); +} diff --git a/src/components/search/search-panel.test.tsx b/src/components/search/search-panel.test.tsx index de73ab4..9078ebc 100644 --- a/src/components/search/search-panel.test.tsx +++ b/src/components/search/search-panel.test.tsx @@ -17,7 +17,15 @@ const MADRID: PhotonResult = { name: "Madrid", city: "Madrid", state: "Comunidad de Madrid", coordinates: [-3.7, 40.4], } as PhotonResult; -function renderPanel() { +// A resolved route so deep-link prefill / route-phase render paths engage. +const ROUTE_FOR_SEED = { + geometry: { type: "LineString" as const, coordinates: [[-1.0, 38.0], [-0.37, 39.47]] }, + distance: 120000, + duration: 4800, + bbox: [-1.0, 38.0, -0.37, 39.47] as [number, number, number, number], +}; + +function renderPanel(props: Partial> = {}) { return render( , ); } @@ -37,12 +46,14 @@ describe("SearchPanel — autocomplete dropdown closes on selection", () => { }); afterEach(() => vi.restoreAllMocks()); - it("closes the origin suggestion list after picking a result", async () => { + // Destination-first flow: the single box on open is the DESTINATION + // (placeholder "search.whereTo"). Picking a suggestion must close the list. + it("closes the destination suggestion list after picking a result", async () => { const user = userEvent.setup(); renderPanel(); - const origin = screen.getByPlaceholderText("search.placeholder"); - await user.type(origin, "Mad"); + const dest = screen.getByPlaceholderText("search.whereTo"); + await user.type(dest, "Mad"); const option = await screen.findByText("Madrid", {}, { timeout: 2000 }); await user.click(option); @@ -54,3 +65,159 @@ describe("SearchPanel — autocomplete dropdown closes on selection", () => { }); }); }); + +describe("SearchPanel — destination-first flow", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [MADRID] })); + }); + afterEach(() => vi.restoreAllMocks()); + + it("shows a single destination box on open (origin section collapsed)", () => { + renderPanel(); + // Primary box uses the "where to?" placeholder (the destination). + expect(screen.getByPlaceholderText("search.whereTo")).toBeInTheDocument(); + // The origin input exists in the DOM (kept mounted for the slide animation) + // but its wrapper is collapsed (max-h-0 opacity-0) — not visible on open. + const originInput = screen.getByPlaceholderText("search.origin"); + const slideWrapper = originInput.closest("div.transition-all"); + expect(slideWrapper).not.toBeNull(); + expect(slideWrapper!.className).toContain("max-h-0"); + expect(slideWrapper!.className).toContain("opacity-0"); + }); + + it("routes straight from userLocation when a destination is picked", async () => { + const user = userEvent.setup(); + const onRoute = vi.fn(); + // Location already shared → origin auto-seeds; picking a destination routes. + renderPanel({ onRoute, userLocation: [-3.70, 40.42] }); + + const dest = screen.getByPlaceholderText("search.whereTo"); + await user.type(dest, "Mad"); + const option = await screen.findByText("Madrid", {}, { timeout: 2000 }); + await user.click(option); + + await waitFor(() => expect(onRoute).toHaveBeenCalledTimes(1)); + // origin = userLocation, destination = Madrid coords. + expect(onRoute).toHaveBeenCalledWith([-3.70, 40.42], [-3.7, 40.4], undefined, undefined); + }); + + it("reveals the origin box (no route yet) when location is unavailable", async () => { + const user = userEvent.setup(); + const onRoute = vi.fn(); + // No userLocation → after picking a destination we must prompt for origin. + renderPanel({ onRoute }); + + const dest = screen.getByPlaceholderText("search.whereTo"); + await user.type(dest, "Mad"); + const option = await screen.findByText("Madrid", {}, { timeout: 2000 }); + await user.click(option); + + // The origin input is always mounted (for the slide animation), so assert + // the REVEAL: its slide wrapper must flip from collapsed (max-h-0) to + // expanded (max-h-[500px]/opacity-100) after the destination is picked. + const originInput = screen.getByPlaceholderText("search.origin"); + const slideWrapper = originInput.closest("div.transition-all"); + expect(slideWrapper).not.toBeNull(); + await waitFor(() => { + expect(slideWrapper!.className).toContain("max-h-[500px]"); + expect(slideWrapper!.className).toContain("opacity-100"); + }); + expect(slideWrapper!.className).not.toContain("max-h-0"); + // NO route was computed (origin still unknown). + expect(onRoute).not.toHaveBeenCalled(); + }); +}); + +describe("SearchPanel — mobile bottom sheet", () => { + const ROUTE = { + geometry: { type: "LineString" as const, coordinates: [[-3.7, 40.4], [-0.37, 39.47]] }, + distance: 6400, + duration: 480, + bbox: [-3.7, 39.47, -0.37, 40.4] as [number, number, number, number], + }; + + // Stub matchMedia so the (max-width: 639px) query reports a match → isMobile. + function stubViewport(isMobile: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((q: string) => ({ + matches: q.includes("max-width: 639px") ? isMobile : false, + media: q, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + } + + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] })); + }); + afterEach(() => vi.restoreAllMocks()); + + it("renders the route in a bottom sheet (role=region) on a mobile viewport", async () => { + stubViewport(true); + renderPanel({ routes: [ROUTE], initialRoute: { from: [-3.7, 40.4], to: [-0.37, 39.47], via: [] } }); + // The sheet is a labelled region landmark; it appears once a route is active. + await waitFor(() => + expect(screen.getByRole("region", { name: "sheet.routeAndStations" })).toBeInTheDocument(), + ); + }); + + it("does NOT render a bottom sheet on desktop (side-card layout)", async () => { + stubViewport(false); + renderPanel({ routes: [ROUTE], initialRoute: { from: [-3.7, 40.4], to: [-0.37, 39.47], via: [] } }); + // Give effects a tick; the sheet region must never appear on desktop. + await waitFor(() => expect(screen.getByText("share.shareRoute")).toBeInTheDocument()); + expect(screen.queryByRole("region", { name: "sheet.routeAndStations" })).not.toBeInTheDocument(); + }); +}); + +describe("SearchPanel — geolocation & auto-seed", () => { + afterEach(() => vi.restoreAllMocks()); + + function stubGeo(impl: Parameters[0]) { + vi.stubGlobal("navigator", { + ...navigator, + geolocation: { getCurrentPosition: vi.fn(impl), watchPosition: vi.fn(), clearWatch: vi.fn() }, + }); + } + + it("shows a transient error and does not strand the user when 'My location' is denied", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [MADRID] })); + // getCurrentPosition invokes the ERROR callback (2nd arg). + stubGeo((_ok: PositionCallback, err: PositionErrorCallback) => + err({ code: 1, message: "denied" } as GeolocationPositionError), + ); + const onRoute = vi.fn(); + renderPanel({ onRoute }); + + // Pick a destination → no userLocation → origin box reveals with "My location". + const dest = screen.getByPlaceholderText("search.whereTo"); + await user.type(dest, "Mad"); + await user.click(await screen.findByText("Madrid", {}, { timeout: 2000 })); + const myLocation = await screen.findByText("geo.myLocation", {}, { timeout: 2000 }); + await user.click(myLocation); + + // Failure surfaces feedback (geo.denied toast) and never computes a route. + await waitFor(() => expect(screen.getByText("geo.denied")).toBeInTheDocument()); + expect(onRoute).not.toHaveBeenCalled(); + }); + + it("auto-seeds origin from userLocation but does NOT clobber a deep-link origin", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] })); + // Deep-link route present AND userLocation present → deep-link owns the origin. + renderPanel({ + userLocation: [-3.70, 40.42], + initialRoute: { from: [-1.0, 38.0], to: [-0.37, 39.47], via: [] }, + routes: [ROUTE_FOR_SEED], + }); + // Origin label is the deep-link "share.point", not "geo.myLocation". + const origin = screen.getByPlaceholderText("search.origin") as HTMLInputElement; + await waitFor(() => expect(origin.value).toBe("share.point")); + expect(origin.value).not.toBe("geo.myLocation"); + }); +}); diff --git a/src/components/search/search-panel.tsx b/src/components/search/search-panel.tsx index 5b0c9a3..a725695 100644 --- a/src/components/search/search-panel.tsx +++ b/src/components/search/search-panel.tsx @@ -7,13 +7,21 @@ import type { StationsGeoJSONCollection } from "@/types/station"; import { AutocompleteInput, type AutocompleteRef } from "./autocomplete-input"; import { RouteAlternatives } from "./route-alternatives"; import { StationResults } from "./station-results"; +import { BottomSheet, type SheetSnap } from "./bottom-sheet"; +import { useMediaQuery } from "@/lib/use-media-query"; import { useI18n } from "@/lib/i18n"; import { projectOntoRoute } from "@/lib/route-geometry"; import { formatDistance, formatDuration } from "@/lib/format"; import { shareOrCopy, copyToClipboard } from "@/lib/share"; import { buildRouteQuery } from "@/lib/share-url"; -type Phase = "search" | "destination" | "route"; +// Destination-first flow: +// "dest" — single box on open; whatever you type is the DESTINATION. +// Origin is hidden (auto-fills from your location when available). +// "planning" — both origin + destination visible, no route yet (origin was +// revealed because location resolved, or you need to type a start). +// "route" — a route is active. +type Phase = "dest" | "planning" | "route"; const MAX_WAYPOINTS = 5; @@ -49,6 +57,12 @@ interface SearchPanelProps { initialRoute?: { from: [number, number]; to: [number, number]; via: [number, number][] } | null; /** Selected fuel code — written into the shared route URL (`fuel=` param). */ selectedFuel?: string; + /** + * The user's current location (lng,lat) once shared, else null. Used to + * auto-fill the ORIGIN in the destination-first flow: when present, picking a + * destination routes straight from "My location" — no manual origin entry. + */ + userLocation?: [number, number] | null; } interface Location { @@ -91,10 +105,17 @@ export function SearchPanel({ onCorridorKmChange, initialRoute, selectedFuel, + userLocation, }: SearchPanelProps) { const { t } = useI18n(); - const [phase, setPhase] = useState("search"); + const isMobile = useMediaQuery("(max-width: 639px)"); + const [phase, setPhase] = useState("dest"); const [collapsed, setCollapsed] = useState(false); + // Mobile bottom-sheet snap point. Starts at "half" so the first stations are + // visible the moment a route resolves, without covering the whole map. + const [sheetSnap, setSheetSnap] = useState("half"); + // Transient toast for a "My location" geolocation failure (auto-clears). + const [geoErrorMsg, setGeoErrorMsg] = useState(null); const [sortBy, setSortBy] = useState<"price" | "detour" | "km">("price"); const [originText, setOriginText] = useState(""); const [destText, setDestText] = useState(""); @@ -105,11 +126,16 @@ export function SearchPanel({ const destRef = useRef(null); const waypointRefs = useRef>(new Map()); const originEditedRef = useRef(false); + // True once the origin has been auto-seeded from the user's location OR the + // user has explicitly set/edited it. Prevents the auto-seed effect from + // re-filling "My location" after the user has taken control of the origin. + const originSeededRef = useRef(false); - // Roll back to destination phase if route calculation failed + // Roll back to the planning phase (both boxes visible) if route calc failed, + // so the user can adjust origin/destination and retry. useEffect(() => { if (routeError && phase === "route" && !routes) { - setPhase("destination"); + setPhase("planning"); } }, [routeError, phase, routes]); @@ -128,34 +154,42 @@ export function SearchPanel({ // When a station-leg preview is active, show its duration/distance instead const displayRoute = displayRoutes?.[0] ?? primaryRoute; - // "My location" handler — triggers geolocation and sets as origin + // "My location" handler — triggers geolocation and sets it as the origin. + // In the destination-first flow, if a destination is already set we route + // straight away; otherwise we keep both boxes open and focus the destination. const handleLocationSelect = useCallback(() => { if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition( (pos) => { const coords: [number, number] = [pos.coords.longitude, pos.coords.latitude]; const label = t("geo.myLocation"); - setOrigin({ label, coordinates: coords }); + const originLoc: Location = { label, coordinates: coords }; + setOrigin(originLoc); setOriginText(label); + originSeededRef.current = true; onFlyTo(coords); - if (phase === "route") { - onClearRoute(); - setDestText(""); - setDestination(null); - setWaypoints([]); + if (destination) { + setPhase("route"); + calculateRoute(originLoc, destination, waypoints); + } else { + setPhase("planning"); + setTimeout(() => destRef.current?.focus(), 100); } - - setPhase("destination"); - setTimeout(() => destRef.current?.focus(), 100); }, () => { - setOriginText(""); - setOrigin(null); + // Geolocation denied / timed out. Give feedback (the old code blanked + // the origin silently). Only clear the origin if it was the unconfirmed + // seed — never wipe a route or a manually-entered origin out from under + // the user; just surface the failure and let them type a start. + setGeoErrorMsg(t("geo.denied")); + setTimeout(() => setGeoErrorMsg(null), 3000); + if (!origin) setTimeout(() => originRef.current?.focus(), 100); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, ); - }, [t, onFlyTo, onClearRoute, phase]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- calculateRoute defined below; stable + }, [t, onFlyTo, origin, destination, waypoints]); // Calculate route with current state const calculateRoute = useCallback( @@ -190,32 +224,55 @@ export function SearchPanel({ setDestText(label); setWaypoints(wps); setPhase("route"); + originSeededRef.current = true; // deep-link owns the origin; don't auto-seed calculateRoute(o, d, wps); // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount prefill; calculateRoute is stable }, [initialRoute]); - // Origin selected + // Auto-seed the origin from the user's shared location (destination-first + // flow). Runs once, when location first becomes available, as long as the + // user hasn't already set/edited the origin and no deep-link route is driving. + // Seeding the origin while still in the single-box "dest" phase keeps the box + // showing the destination — the origin only becomes visible once the user + // picks a destination (handleDestSelect) or reveals it manually. + useEffect(() => { + if (originSeededRef.current || initialRoute) return; + if (!userLocation) return; + originSeededRef.current = true; + const label = t("geo.myLocation"); + setOrigin({ label, coordinates: userLocation }); + // Set the text too so the origin box shows "My location" when it slides in + // (the box is hidden in the single-box "dest" phase, so this is invisible + // until the user picks a destination and the origin row appears). + setOriginText(label); + // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot seed on first location + }, [userLocation]); + + // Origin selected (manual) — the user took control of the start point. const handleOriginSelect = useCallback( (result: PhotonResult) => { const loc: Location = { label: formatResult(result), coordinates: result.coordinates }; setOrigin(loc); setOriginText(formatResult(result)); + originSeededRef.current = true; onFlyTo(result.coordinates); - if (phase === "route") { - onClearRoute(); - setDestText(""); - setDestination(null); - setWaypoints([]); + // Both endpoints known → route immediately; else stay in planning. + if (destination) { + setPhase("route"); + calculateRoute(loc, destination, waypoints); + } else { + setPhase("planning"); + setTimeout(() => destRef.current?.focus(), 100); } - - setPhase("destination"); - setTimeout(() => destRef.current?.focus(), 100); }, - [onFlyTo, onClearRoute, phase], + [onFlyTo, destination, waypoints, calculateRoute], ); - // Destination selected → auto-calculate route + // Destination selected → the heart of the destination-first flow. + // - origin already known (seeded from location or set manually) → route now; + // - no origin yet → reveal the origin box and focus it so the user can type + // a start (the "location unavailable" fallback). const handleDestSelect = useCallback( (result: PhotonResult) => { const loc: Location = { label: formatResult(result), coordinates: result.coordinates }; @@ -225,6 +282,9 @@ export function SearchPanel({ if (origin) { setPhase("route"); calculateRoute(origin, loc, waypoints); + } else { + setPhase("planning"); + setTimeout(() => originRef.current?.focus(), 100); } }, [origin, waypoints, calculateRoute], @@ -259,62 +319,68 @@ export function SearchPanel({ [], ); + // Editing the ORIGIN (secondary box). Origin changes invalidate the route but + // — unlike the old origin-first flow — must NOT wipe the destination, which is + // now the primary field the user started from. const handleOriginChange = useCallback( (val: string) => { setOriginText(val); - if (phase === "route" || phase === "destination") { - originEditedRef.current = true; + originEditedRef.current = true; + originSeededRef.current = true; // user has taken control of the origin + setOrigin(null); + if (phase === "route") { onClearRoute(); - setDestText(""); - setDestination(null); - setOrigin(null); - setWaypoints([]); - setPhase("search"); + setWaypoints((prev) => prev.filter((wp) => !wp.isStationLeg)); + setPhase("planning"); } }, [phase, onClearRoute], ); + // Editing the DESTINATION (primary box). Invalidates the route; keeps the + // origin so the user can just retype where they're going. const handleDestChange = useCallback( (val: string) => { setDestText(val); setDestination(null); if (phase === "route") { onClearRoute(); - setWaypoints([]); - setPhase("destination"); + setWaypoints((prev) => prev.filter((wp) => !wp.isStationLeg)); + setPhase(origin ? "planning" : "dest"); } else if (routeError) { onClearRoute(); } }, - [phase, onClearRoute, routeError], + [phase, onClearRoute, routeError, origin], ); const handleWaypointChange = useCallback((wpId: number, val: string) => { setWaypoints((prev) => prev.map((wp) => (wp.id === wpId ? { ...wp, text: val, location: null, isStationLeg: false } : wp))); if (phase === "route") { onClearRoute(); - setPhase("destination"); + setPhase("planning"); } }, [phase, onClearRoute]); const handleOriginEnter = useCallback(async () => { if (!originText.trim()) return; if (origin) { - setPhase("destination"); - setTimeout(() => destRef.current?.focus(), 100); + if (destination) { setPhase("route"); calculateRoute(origin, destination, waypoints); } + else setTimeout(() => destRef.current?.focus(), 100); return; } const result = await originRef.current?.geocode(originText.trim()); if (result) handleOriginSelect(result); - }, [originText, origin, handleOriginSelect]); + }, [originText, origin, destination, waypoints, calculateRoute, handleOriginSelect]); + // Enter on the destination box. No longer requires a pre-set origin — that's + // the whole point of destination-first: pick where you're going, and either + // route from your location or get prompted for a start. const handleDestEnter = useCallback(async () => { - if (!destText.trim() || !origin) return; + if (!destText.trim()) return; if (destination) { - // Allow retry (e.g. after route failure) by re-triggering calculation - setPhase("route"); - calculateRoute(origin, destination, waypoints); + if (origin) { setPhase("route"); calculateRoute(origin, destination, waypoints); } + else { setPhase("planning"); setTimeout(() => originRef.current?.focus(), 100); } return; } const result = await destRef.current?.geocode(destText.trim()); @@ -336,12 +402,13 @@ export function SearchPanel({ originEditedRef.current = false; }, []); + // If the user focused the origin box but blurred without editing, restore the + // resolved label (e.g. "My location") so a stray focus can't leave it blank. const handleOriginBlur = useCallback(() => { - if (!originEditedRef.current && origin && phase === "search") { + if (!originEditedRef.current && origin && originText !== origin.label) { setOriginText(origin.label); - setPhase(destination ? "route" : "destination"); } - }, [origin, destination, phase]); + }, [origin, originText]); // Swap origin ↔ destination const handleSwap = useCallback(() => { @@ -542,16 +609,18 @@ export function SearchPanel({ } }, [routeShareUrl]); - const showDest = phase === "destination" || phase === "route"; + // In the destination-first flow the DESTINATION is the always-visible primary + // box; the ORIGIN (+ waypoints) slide in above it once planning starts. + const showOrigin = phase === "planning" || phase === "route"; - const [destVisible, setDestVisible] = useState(false); + const [originVisible, setOriginVisible] = useState(false); useEffect(() => { - if (showDest) { - const t = setTimeout(() => setDestVisible(true), 300); + if (showOrigin) { + const t = setTimeout(() => setOriginVisible(true), 300); return () => clearTimeout(t); } - setDestVisible(false); - }, [showDest]); + setOriginVisible(false); + }, [showOrigin]); // All corridor stations (price may be null for EV chargers) const allCorridorStations = useMemo( @@ -630,62 +699,174 @@ export function SearchPanel({ return { avgPrice: avg, cheapestId: cheapest, shortestDetourId: shortestDetour, balancedId: balanced }; }, [stationList]); - return ( + // Route content (alternatives + share + station list). Shared verbatim by the + // desktop side-card layout and the mobile bottom sheet. `collapsed` is a + // desktop-only control (its toggle is gated `!isMobile`), so on mobile we + // force it off — otherwise collapsing on desktop then resizing to mobile would + // render an empty sheet with no way to re-expand (the toggle is hidden there). + const routeCollapsed = collapsed && !isMobile; + const routeContent = ( + <> + {/* Route info + alternatives — hidden when collapsed */} + {primaryRoute && !routeCollapsed && ( +
+ {/* All routes — selected one shows preview metrics when active */} + {routes && ( + + )} + {/* Share / Copy route — both build the same deep-link URL; Share opens + the native sheet (clipboard fallback), Copy writes it directly. */} + {origin && destination && ( +
+ + +
+ )} +
+ )} + + {/* Loading spinner while stations are being fetched */} + {phase === "route" && stationsLoading && allCorridorStations.length === 0 && !routeCollapsed && ( +
+
+
+ {t("stations.loading")} +
+
+ )} + + {/* Empty state when corridor loading finishes with zero stations */} + {phase === "route" && !stationsLoading && allCorridorStations.length === 0 && routes && !routeCollapsed && ( +
+ {t(stationsError ? "stations.loadError" : "stations.noStations")} +
+ )} + + {/* Station list along route — hidden when collapsed */} + {phase === "route" && allCorridorStations.length > 0 && !routeCollapsed && ( + { + // Toggle off: remove station-leg waypoint and clear preview + setWaypoints((prev) => prev.filter((wp) => !wp.isStationLeg)); + onClearStationLeg?.(); + // On mobile, drop the sheet to peek so the map is visible again. + if (isMobile) setSheetSnap("peek"); + }} + onStationSelect={(coords, sid) => { + onFlyTo(coords, sid); + // On mobile, lower the sheet to peek so the picked station shows on + // the map; the sheet can be dragged back up to keep browsing. + if (isMobile) setSheetSnap("peek"); + }} + /> + )} + + ); + + const desktopPanel = (
{/* Search card */}
- {/* Origin row */} -
-
- {showDest ? ( + {/* Origin + waypoints — destination-first: hidden on open, slides in + above the destination once planning starts. Hidden when collapsed. */} +
+ {/* Origin row */} +
+
- ) : ( - - - +
+ setOrigin(null)} + onEnter={handleOriginEnter} + onFocus={handleOriginFocus} + onBlur={handleOriginBlur} + mapCenter={mapCenter} + bare + locationLabel={t("geo.myLocation")} + onLocationSelect={handleLocationSelect} + /> + {originText && ( + )}
- setOrigin(null)} - onEnter={handleOriginEnter} - onFocus={handleOriginFocus} - onBlur={handleOriginBlur} - mapCenter={mapCenter} - bare - locationLabel={t("geo.myLocation")} - onLocationSelect={handleLocationSelect} - /> - {originText && ( - - )} -
- {/* Destination + waypoints — slides in, hidden when collapsed */} -
{/* Waypoints (between origin and destination) */} {waypoints.map((wp, idx) => (
@@ -752,67 +933,75 @@ export function SearchPanel({ )}
+
- {/* Destination row */} -
-
+ {/* Destination row — the always-visible primary box (single box on open) */} +
+
+ {showOrigin ? ( -
- setDestination(null)} - onEnter={handleDestEnter} - mapCenter={mapCenter} - bare - /> - {destText && ( - + ) : ( + + + )}
- - {/* Add waypoint button */} - {showDest && waypoints.length < MAX_WAYPOINTS && ( -
- -
+ setDestination(null)} + onEnter={handleDestEnter} + mapCenter={mapCenter} + bare + /> + {destText && ( + )}
- {/* Collapse toggle — when route is active */} - {phase === "route" && primaryRoute && ( + {/* Add waypoint button */} + {showOrigin && !collapsed && waypoints.length < MAX_WAYPOINTS && ( +
+ +
+ )} + + {/* Collapse toggle — desktop only. On mobile the bottom-sheet drag + handle is the collapse/expand affordance, so this is hidden there. */} + {!isMobile && phase === "route" && primaryRoute && (
)} - - {/* Route info + alternatives — hidden when collapsed */} - {primaryRoute && !collapsed && ( -
- {/* All routes — selected one shows preview metrics when active */} - {routes && ( - - )} - {/* Share / Copy route — both build the same deep-link URL; Share opens - the native sheet (clipboard fallback), Copy writes it directly. */} - {origin && destination && ( -
- - -
- )} + {/* Geolocation failure — transient amber toast when "My location" fails */} + {geoErrorMsg && ( +
+ {geoErrorMsg}
)} - {/* Loading spinner while stations are being fetched */} - {phase === "route" && stationsLoading && allCorridorStations.length === 0 && !collapsed && ( -
-
-
- {t("stations.loading")} -
-
- )} + {/* Route content — desktop stacks it in the side card; mobile renders it + in the bottom sheet (below) instead so the list never pushes off-screen. */} + {!isMobile && routeContent} +
+ ); - {/* Empty state when corridor loading finishes with zero stations */} - {phase === "route" && !stationsLoading && allCorridorStations.length === 0 && routes && !collapsed && ( -
- {t(stationsError ? "stations.loadError" : "stations.noStations")} -
- )} + // Desktop (≥640px): the side-card layout above. Mobile (≤639px): the map fills + // the viewport and the route + stations ride in a draggable bottom sheet, so + // the station list scrolls inside the sheet rather than being pushed below the + // fold (the reported problem). The search card stays pinned at the top. + if (!isMobile) return desktopPanel; - {/* Station list along route — hidden when collapsed */} - {phase === "route" && allCorridorStations.length > 0 && !collapsed && ( - { - // Toggle off: remove station-leg waypoint and clear preview - setWaypoints((prev) => prev.filter((wp) => !wp.isStationLeg)); - onClearStationLeg?.(); - if (window.matchMedia("(max-width: 639px)").matches) setCollapsed(true); - }} - onStationSelect={(coords, sid) => { - onFlyTo(coords, sid); - if (window.matchMedia("(max-width: 639px)").matches) setCollapsed(true); - }} - /> + return ( + <> + {desktopPanel} + {phase === "route" && primaryRoute && ( + +
+ {formatDistance(displayRoute!.distance)} + · + {formatDuration(displayRoute!.duration)} + + {t("stations.title")} ({allCorridorStations.length}) + +
+ } + > + {routeContent} +
)} -
+ ); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index bd94f0a..5b4e129 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -31,6 +31,10 @@ const translations: Record> = { es: { "search.placeholder": "Buscar lugar...", "search.destination": "Destino", + "search.origin": "Origen", + "search.whereTo": "¿A dónde vas?", + "sheet.routeAndStations": "Ruta y estaciones", + "sheet.resize": "Cambiar tamaño del panel", "search.addWaypoint": "Añadir parada", "search.waypoint": "Parada intermedia", "search.swap": "Intercambiar origen y destino", @@ -91,6 +95,10 @@ const translations: Record> = { en: { "search.placeholder": "Search place...", "search.destination": "Destination", + "search.origin": "Origin", + "search.whereTo": "Where to?", + "sheet.routeAndStations": "Route and stations", + "sheet.resize": "Resize panel", "search.addWaypoint": "Add stop", "search.waypoint": "Intermediate stop", "search.swap": "Swap origin and destination", @@ -151,6 +159,10 @@ const translations: Record> = { fr: { "search.placeholder": "Rechercher un lieu...", "search.destination": "Destination", + "search.origin": "Origine", + "search.whereTo": "Où allez-vous ?", + "sheet.routeAndStations": "Itinéraire et stations", + "sheet.resize": "Redimensionner le panneau", "search.addWaypoint": "Ajouter un arrêt", "search.waypoint": "Arrêt intermédiaire", "search.swap": "Inverser origine et destination", @@ -211,6 +223,10 @@ const translations: Record> = { de: { "search.placeholder": "Ort suchen...", "search.destination": "Ziel", + "search.origin": "Start", + "search.whereTo": "Wohin?", + "sheet.routeAndStations": "Route und Tankstellen", + "sheet.resize": "Panelgröße ändern", "search.addWaypoint": "Zwischenstopp", "search.waypoint": "Zwischenziel", "search.swap": "Start und Ziel tauschen", @@ -271,6 +287,10 @@ const translations: Record> = { it: { "search.placeholder": "Cerca luogo...", "search.destination": "Destinazione", + "search.origin": "Origine", + "search.whereTo": "Dove vai?", + "sheet.routeAndStations": "Percorso e stazioni", + "sheet.resize": "Ridimensiona il pannello", "search.addWaypoint": "Aggiungi tappa", "search.waypoint": "Tappa intermedia", "search.swap": "Inverti partenza e arrivo", @@ -331,6 +351,10 @@ const translations: Record> = { pt: { "search.placeholder": "Pesquisar local...", "search.destination": "Destino", + "search.origin": "Origem", + "search.whereTo": "Para onde vais?", + "sheet.routeAndStations": "Rota e estações", + "sheet.resize": "Redimensionar painel", "search.addWaypoint": "Adicionar paragem", "search.waypoint": "Paragem intermédia", "search.swap": "Trocar origem e destino", @@ -391,6 +415,10 @@ const translations: Record> = { pl: { "search.placeholder": "Szukaj miejsca...", "search.destination": "Cel", + "search.origin": "Początek", + "search.whereTo": "Dokąd jedziesz?", + "sheet.routeAndStations": "Trasa i stacje", + "sheet.resize": "Zmień rozmiar panelu", "search.addWaypoint": "Dodaj przystanek", "search.waypoint": "Przystanek pośredni", "search.swap": "Zamień start i cel", @@ -451,6 +479,10 @@ const translations: Record> = { cs: { "search.placeholder": "Hledat místo...", "search.destination": "Cíl", + "search.origin": "Odkud", + "search.whereTo": "Kam jedeš?", + "sheet.routeAndStations": "Trasa a stanice", + "sheet.resize": "Změnit velikost panelu", "search.addWaypoint": "Přidat zastávku", "search.waypoint": "Mezizastávka", "search.swap": "Prohodit start a cíl", @@ -511,6 +543,10 @@ const translations: Record> = { hu: { "search.placeholder": "Hely keresése...", "search.destination": "Úti cél", + "search.origin": "Honnan", + "search.whereTo": "Hová mész?", + "sheet.routeAndStations": "Útvonal és állomások", + "sheet.resize": "Panel átméretezése", "search.addWaypoint": "Megálló hozzáadása", "search.waypoint": "Köztes megálló", "search.swap": "Indulás és cél csere", @@ -571,6 +607,10 @@ const translations: Record> = { bg: { "search.placeholder": "Търсене на място...", "search.destination": "Дестинация", + "search.origin": "Начало", + "search.whereTo": "Накъде?", + "sheet.routeAndStations": "Маршрут и станции", + "sheet.resize": "Преоразмеряване на панела", "search.addWaypoint": "Добави спирка", "search.waypoint": "Междинна спирка", "search.swap": "Размени старт и край", @@ -631,6 +671,10 @@ const translations: Record> = { sk: { "search.placeholder": "Hľadať miesto...", "search.destination": "Cieľ", + "search.origin": "Odkiaľ", + "search.whereTo": "Kam ideš?", + "sheet.routeAndStations": "Trasa a stanice", + "sheet.resize": "Zmeniť veľkosť panela", "search.addWaypoint": "Pridať zastávku", "search.waypoint": "Medzizastávka", "search.swap": "Vymeniť štart a cieľ", @@ -691,6 +735,10 @@ const translations: Record> = { da: { "search.placeholder": "Søg sted...", "search.destination": "Destination", + "search.origin": "Start", + "search.whereTo": "Hvorhen?", + "sheet.routeAndStations": "Rute og stationer", + "sheet.resize": "Tilpas panelstørrelse", "search.addWaypoint": "Tilføj stop", "search.waypoint": "Mellemstop", "search.swap": "Byt start og mål", @@ -751,6 +799,10 @@ const translations: Record> = { sv: { "search.placeholder": "Sök plats...", "search.destination": "Destination", + "search.origin": "Start", + "search.whereTo": "Vart?", + "sheet.routeAndStations": "Rutt och stationer", + "sheet.resize": "Ändra panelstorlek", "search.addWaypoint": "Lägg till stopp", "search.waypoint": "Mellanstopp", "search.swap": "Byt start och mål", @@ -811,6 +863,10 @@ const translations: Record> = { no: { "search.placeholder": "Søk sted...", "search.destination": "Destinasjon", + "search.origin": "Start", + "search.whereTo": "Hvor skal du?", + "sheet.routeAndStations": "Rute og stasjoner", + "sheet.resize": "Endre panelstørrelse", "search.addWaypoint": "Legg til stopp", "search.waypoint": "Mellomstopp", "search.swap": "Bytt start og mål", @@ -871,6 +927,10 @@ const translations: Record> = { sr: { "search.placeholder": "Pretraži mesto...", "search.destination": "Odredište", + "search.origin": "Polazak", + "search.whereTo": "Kuda ideš?", + "sheet.routeAndStations": "Ruta i stanice", + "sheet.resize": "Promeni veličinu panela", "search.addWaypoint": "Dodaj stanicu", "search.waypoint": "Međustanica", "search.swap": "Zameni start i cilj", @@ -931,6 +991,10 @@ const translations: Record> = { fi: { "search.placeholder": "Etsi paikka...", "search.destination": "Määränpää", + "search.origin": "Lähtö", + "search.whereTo": "Minne?", + "sheet.routeAndStations": "Reitti ja asemat", + "sheet.resize": "Muuta paneelin kokoa", "search.addWaypoint": "Lisää pysähdys", "search.waypoint": "Välipysähdys", "search.swap": "Vaihda lähtö ja määränpää", diff --git a/src/lib/use-media-query.ts b/src/lib/use-media-query.ts new file mode 100644 index 0000000..7d5b0cc --- /dev/null +++ b/src/lib/use-media-query.ts @@ -0,0 +1,37 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * SSR-safe media-query hook built on useSyncExternalStore — the idiomatic way to + * subscribe to an external browser source. The server snapshot is `false`, so + * SSR and the first client render both render the desktop baseline (hydration + * matches); the client snapshot reflects the real match and updates on change. + * + * Defaulting to `false` keeps desktop as the baseline: tests (jsdom, where + * matchMedia is absent / `matches:false`) and SSR render the desktop path, and + * only real mobile viewports flip to `true`. + */ +export function useMediaQuery(query: string): boolean { + const subscribe = (onStoreChange: () => void): (() => void) => { + if (typeof window === "undefined" || !window.matchMedia) return () => {}; + const mql = window.matchMedia(query); + // addEventListener is the modern API; guard for older Safari (addListener). + if (mql.addEventListener) mql.addEventListener("change", onStoreChange); + else mql.addListener(onStoreChange); + return () => { + if (mql.removeEventListener) mql.removeEventListener("change", onStoreChange); + else mql.removeListener(onStoreChange); + }; + }; + + const getSnapshot = (): boolean => { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia(query).matches; + }; + + // Server snapshot: always false (desktop baseline; avoids hydration mismatch). + const getServerSnapshot = (): boolean => false; + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +}