diff --git a/src/components/home-client.deeplink.test.tsx b/src/components/home-client.deeplink.test.tsx index f550096..01fd772 100644 --- a/src/components/home-client.deeplink.test.tsx +++ b/src/components/home-client.deeplink.test.tsx @@ -155,6 +155,27 @@ describe("HomeClient — deep-link read on load", () => { expect(searchPanelProps.initialRoute).toBeNull(); }); + it("applies the fuel from a station deep-link so the shared layer is the one that loads (#129)", async () => { + // A charger shared from the EV layer: the viewport fetch is fuel-filtered, + // so unless the link switches the visitor to EV the charger never loads. + window.history.replaceState(null, "", "/es?station=ES:reve-1&lat=40.4768&lng=-3.6891&fuel=EV"); + renderHome(); + + expect(searchPanelProps.selectedFuel).toBe("EV"); + expect(searchPanelProps.initialRoute).toBeNull(); + await waitFor(() => expect(flyTo).toHaveBeenCalled()); + }); + + it("keeps the default fuel when a station deep-link carries an invalid or no fuel", () => { + window.history.replaceState(null, "", "/es?station=ES:12345&lat=40.41672&lng=-3.70379&fuel=NOTAFUEL"); + renderHome(); + expect(searchPanelProps.selectedFuel).toBe("E5"); + + window.history.replaceState(null, "", "/es?station=ES:12345&lat=40.41672&lng=-3.70379"); + renderHome(); + expect(searchPanelProps.selectedFuel).toBe("E5"); + }); + it("resolves & selects a station deep-link from the lifted on-screen stations", async () => { // The real MapView lifts on-screen bbox stations to the parent; the resolve // effect matches the deep-linked station (externalId+country) and selects it. diff --git a/src/components/home-client.tsx b/src/components/home-client.tsx index 80f479a..14547a7 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -57,7 +57,11 @@ function readDeepLink(): { fuel: FuelType | null; route: InitialRoute | null; st const station = parseStationParams(sp); if (station && station.lat != null && station.lng != null) { - return { fuel: null, route: null, station }; + // Same fuel handling as the route branch: the viewport fetch is fuel- + // filtered, so an EV charger shared from the EV layer only ever loads (and + // gets selected) if the link puts the visitor on that layer (#129). + const parsedFuel = fuelTypeEnum.safeParse(station.fuel); + return { fuel: parsedFuel.success ? parsedFuel.data : null, route: null, station }; } return empty; } @@ -476,10 +480,10 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale const country = feature?.properties.country; if (feature && extId != null && country != null) { const [lng, lat] = feature.geometry.coordinates; - const qs = buildStationQuery({ country, externalId: extId, lat, lng }).toString(); + const qs = buildStationQuery({ country, externalId: extId, lat, lng, fuel: selectedFuel }).toString(); window.history.replaceState(null, "", `${pathname}?${qs}`); } - }, [selectedStationId, routeState, primaryStations]); + }, [selectedStationId, routeState, primaryStations, selectedFuel]); // Streaming Valhalla-based detour calculation — results appear per-station const { detourMap, detoursLoading } = useDetourStream({ primaryStations, routeState, detourBasis }); diff --git a/src/components/map/station-popup.test.tsx b/src/components/map/station-popup.test.tsx index def801e..aa8e935 100644 --- a/src/components/map/station-popup.test.tsx +++ b/src/components/map/station-popup.test.tsx @@ -108,6 +108,8 @@ describe("StationPopup", () => { expect(copied).toContain("station=ES%3A4710"); expect(copied).toContain("lat=40.4168"); expect(copied).toContain("lng=-3.7038"); + // The layer the popup was opened from travels with the link (#129). + expect(copied).toContain("fuel=E5"); vi.unstubAllGlobals(); }); }); diff --git a/src/components/map/station-popup.tsx b/src/components/map/station-popup.tsx index b90e17d..8941181 100644 --- a/src/components/map/station-popup.tsx +++ b/src/components/map/station-popup.tsx @@ -51,14 +51,17 @@ export function StationPopup({ station, onClose }: StationPopupProps) { const lat = geometry.coordinates[1]; const lng = geometry.coordinates[0]; - // Absolute deep-link to this station (?station=CC:extId&lat&lng on the - // current locale path) — shared and copied by the action buttons below. + // Absolute deep-link to this station (?station=CC:extId&lat&lng&fuel on the + // current locale path) — shared and copied by the action buttons below. The + // fuel is the layer this popup was opened from; without it an EV charger + // link would open on the default fuel and never load the charger (#129). function shareUrl(): string { const sp = buildStationQuery({ country: properties.country ?? "", externalId: properties.externalId ?? "", lat, lng, + fuel: properties.fuelType, }); return `${window.location.origin}${window.location.pathname}?${sp}`; } diff --git a/src/lib/share-url.test.ts b/src/lib/share-url.test.ts index 49b336d..9ef78e5 100644 --- a/src/lib/share-url.test.ts +++ b/src/lib/share-url.test.ts @@ -117,9 +117,29 @@ describe("buildStationQuery / parseStationParams round-trip", () => { externalId: "12345", lat: 40.41672, lng: -3.70379, + fuel: null, }); }); + it("carries the fuel the station was shared from (#129)", () => { + // An EV charger only loads on the EV layer: without the fuel, a fresh + // visitor lands on the default fuel and the popup can never open. + const sp = buildStationQuery({ country: "ES", externalId: "reve-1", lat: 40.4768, lng: -3.6891, fuel: "EV" }); + expect(sp.get("fuel")).toBe("EV"); + expect(parseStationParams(sp)?.fuel).toBe("EV"); + }); + + it("omits the fuel param when none is given", () => { + const sp = buildStationQuery({ country: "ES", externalId: "1", lat: 1, lng: 2, fuel: "" }); + expect(sp.has("fuel")).toBe(false); + expect(parseStationParams(sp)?.fuel).toBeNull(); + }); + + it("passes fuel through raw without validating it", () => { + const parsed = parseStationParams(new URLSearchParams("station=ES:1&lat=1&lng=2&fuel=NOTAFUEL")); + expect(parsed?.fuel).toBe("NOTAFUEL"); + }); + it("uppercases the country on build", () => { const sp = buildStationQuery({ country: "es", externalId: "abc", lat: 1, lng: 2 }); expect(sp.get("station")).toBe("ES:abc"); @@ -145,7 +165,7 @@ describe("buildStationQuery / parseStationParams round-trip", () => { const sp = new URLSearchParams(); sp.set("station", "ES"); const parsed = parseStationParams(sp); - expect(parsed).toEqual({ country: "ES", externalId: null, lat: null, lng: null }); + expect(parsed).toEqual({ country: "ES", externalId: null, lat: null, lng: null, fuel: null }); }); it("yields null coords when lat/lng are out of range", () => { @@ -169,6 +189,7 @@ describe("buildStationQuery / parseStationParams round-trip", () => { externalId: null, lat: 40.41672, lng: -3.70379, + fuel: null, }); }); diff --git a/src/lib/share-url.ts b/src/lib/share-url.ts index ba85f22..12ee633 100644 --- a/src/lib/share-url.ts +++ b/src/lib/share-url.ts @@ -8,11 +8,14 @@ * Applying the result to the current browser URL lives in components, not here. * * URL shapes: - * STATION: /{locale}?station={COUNTRY}:{externalId}&lat={LAT}&lng={LNG} + * STATION: /{locale}?station={COUNTRY}:{externalId}&lat={LAT}&lng={LNG}&fuel={CODE} * ROUTE: /{locale}?from={LAT,LNG}&to={LAT,LNG}&via={LAT,LNG}&...&fuel={CODE} * * Stations are keyed on the durable COUNTRY:externalId (UUIDs churn on re-import); * lat/lng is the recenter fallback. Coordinates are rounded to 5 decimal places. + * `fuel` is the layer the station was shared from: the viewport fetch is + * fuel-filtered, so without it an EV charger link opens on the default fuel and + * the charger is never loaded, let alone selected (#129). */ /** Maximum number of `via` waypoints honoured when parsing a route. */ @@ -56,14 +59,17 @@ export interface StationShareParams { externalId: string; lat: number; lng: number; + /** Fuel code the station was shared from; omitted when empty. */ + fuel?: string; } -/** Build the query for a shared station: `station=CC:extId&lat=..&lng=..`. */ +/** Build the query for a shared station: `station=CC:extId&lat=..&lng=..&fuel=..`. */ export function buildStationQuery(p: StationShareParams): URLSearchParams { const sp = new URLSearchParams(); sp.set("station", `${p.country.toUpperCase()}:${p.externalId}`); sp.set("lat", String(roundCoord(p.lat))); sp.set("lng", String(roundCoord(p.lng))); + if (p.fuel) sp.set("fuel", p.fuel); return sp; } @@ -71,13 +77,15 @@ export function buildStationQuery(p: StationShareParams): URLSearchParams { * Parse station params from a query. Returns null when no station-ish params are * present at all. The `station` value is split on the FIRST colon only, since the * external id may itself contain colons. lat/lng are validated independently and - * may be null while country/externalId are present (and vice versa). + * may be null while country/externalId are present (and vice versa). `fuel` is + * returned raw (null when absent); the caller validates it against `fuelTypeEnum`. */ export function parseStationParams(sp: URLSearchParams): { country: string | null; externalId: string | null; lat: number | null; lng: number | null; + fuel: string | null; } | null { const station = sp.get("station"); const latRaw = sp.get("lat"); @@ -101,7 +109,7 @@ export function parseStationParams(sp: URLSearchParams): { const lat = parseCoordComponent(latRaw, -90, 90); const lng = parseCoordComponent(lngRaw, -180, 180); - return { country, externalId, lat, lng }; + return { country, externalId, lat, lng, fuel: sp.get("fuel") }; } // ---------------------------------------------------------------------------