diff --git a/src/components/search/search-panel.deeplink.test.tsx b/src/components/search/search-panel.deeplink.test.tsx index b0b957c..5ebb1b8 100644 --- a/src/components/search/search-panel.deeplink.test.tsx +++ b/src/components/search/search-panel.deeplink.test.tsx @@ -12,10 +12,14 @@ vi.mock("@/lib/currency", () => ({ vi.mock("./route-alternatives", () => ({ RouteAlternatives: () => null })); vi.mock("./station-results", () => ({ StationResults: () => null })); -// Spy on shareOrCopy so the "Share route" button can be asserted without touching -// the Web Share API. +// Spy on shareOrCopy + copyToClipboard so the Share/Copy route buttons can be +// asserted without touching the Web Share / Clipboard APIs. const shareOrCopy = vi.fn().mockResolvedValue("copied"); -vi.mock("@/lib/share", () => ({ shareOrCopy: (data: unknown) => shareOrCopy(data) })); +const copyToClipboard = vi.fn().mockResolvedValue(true); +vi.mock("@/lib/share", () => ({ + shareOrCopy: (data: unknown) => shareOrCopy(data), + copyToClipboard: (text: string) => copyToClipboard(text), +})); const ROUTE: Route = { geometry: { type: "LineString", coordinates: [[-3.7, 40.4], [-0.37, 39.47]] }, @@ -117,6 +121,7 @@ describe("SearchPanel — Share route button", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] })); shareOrCopy.mockClear(); + copyToClipboard.mockClear(); }); afterEach(() => vi.restoreAllMocks()); @@ -151,4 +156,35 @@ describe("SearchPanel — Share route button", () => { // After a copy, the button flips to the copied label. await waitFor(() => expect(screen.getByText("share.copied")).toBeInTheDocument()); }); + + it("copy-route button copies the same route URL directly to the clipboard", async () => { + const user = userEvent.setup(); + render( + {}} + onRoute={() => {}} + onClearRoute={() => {}} + routes={[ROUTE]} + primaryRouteIndex={0} + isLoading={false} + initialRoute={INITIAL_ROUTE} + selectedFuel="B7" + />, + ); + + // The Copy button (popup.copyLink label) sits next to Share route. + const copyBtn = await screen.findByText("popup.copyLink"); + await user.click(copyBtn); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalledTimes(1)); + const url = copyToClipboard.mock.calls[0][0] as string; + expect(url).toContain("from=40.41672%2C-3.70379"); + expect(url).toContain("to=39.46975%2C-0.37739"); + expect(url).toContain("fuel=B7"); + // Share sheet was NOT invoked for a direct copy. + expect(shareOrCopy).not.toHaveBeenCalled(); + // Button flips to the copied label. + await waitFor(() => expect(screen.getByText("share.copied")).toBeInTheDocument()); + }); }); diff --git a/src/components/search/search-panel.tsx b/src/components/search/search-panel.tsx index 8dcca2c..5b0c9a3 100644 --- a/src/components/search/search-panel.tsx +++ b/src/components/search/search-panel.tsx @@ -10,7 +10,7 @@ import { StationResults } from "./station-results"; import { useI18n } from "@/lib/i18n"; import { projectOntoRoute } from "@/lib/route-geometry"; import { formatDistance, formatDuration } from "@/lib/format"; -import { shareOrCopy } from "@/lib/share"; +import { shareOrCopy, copyToClipboard } from "@/lib/share"; import { buildRouteQuery } from "@/lib/share-url"; type Phase = "search" | "destination" | "route"; @@ -504,10 +504,12 @@ export function SearchPanel({ window.history.replaceState(null, "", `${window.location.pathname}?${qs}`); }, [routes, origin, destination, waypoints, selectedFuel]); - // "Share route" button — builds the absolute route URL and shares/copies it. + // Share/copy the current route. The absolute deep-link URL is built once and + // reused by both the Share (native sheet) and Copy (clipboard-direct) actions. const [shareCopied, setShareCopied] = useState(false); - const handleShareRoute = useCallback(async () => { - if (!origin || !destination) return; + const [linkCopied, setLinkCopied] = useState(false); + const routeShareUrl = useCallback((): string | null => { + if (!origin || !destination) return null; const via = waypoints .filter((wp) => wp.location != null) .map((wp) => ({ lng: wp.location!.coordinates[0], lat: wp.location!.coordinates[1] })); @@ -517,7 +519,12 @@ export function SearchPanel({ via, fuel: selectedFuel ?? "", }).toString(); - const url = `${window.location.origin}${window.location.pathname}?${qs}`; + return `${window.location.origin}${window.location.pathname}?${qs}`; + }, [origin, destination, waypoints, selectedFuel]); + + const handleShareRoute = useCallback(async () => { + const url = routeShareUrl(); + if (!url) return; // Share only { title, url } — a `text` field is prepended to the URL by // many native share targets, which appended a redundant "Pumperly route". const outcome = await shareOrCopy({ title: t("share.routeTitle"), url }); @@ -525,7 +532,15 @@ export function SearchPanel({ setShareCopied(true); setTimeout(() => setShareCopied(false), 2000); } - }, [origin, destination, waypoints, selectedFuel, t]); + }, [routeShareUrl, t]); + + const handleCopyRoute = useCallback(async () => { + const url = routeShareUrl(); + if (url && (await copyToClipboard(url))) { + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + } + }, [routeShareUrl]); const showDest = phase === "destination" || phase === "route"; @@ -843,23 +858,41 @@ export function SearchPanel({ onSelectRoute={onSelectRoute} /> )} - {/* Share route — builds the deep-link URL and opens the share sheet / copies it */} + {/* Share / Copy route — both build the same deep-link URL; Share opens + the native sheet (clipboard fallback), Copy writes it directly. */} {origin && destination && ( - +
+ + +
)} )}