diff --git a/src/components/map/station-popup.test.tsx b/src/components/map/station-popup.test.tsx index a12c612..def801e 100644 --- a/src/components/map/station-popup.test.tsx +++ b/src/components/map/station-popup.test.tsx @@ -82,4 +82,32 @@ describe("StationPopup", () => { expect(screen.getByText(/popup\.noPrice/)).toBeInTheDocument(); expect(screen.queryByText("€/L")).not.toBeInTheDocument(); }); + + it("renders the action row: directions, show-on-map (pin), copy link, share", () => { + render( {}} />); + // Navigate is a labelled link to Google Maps directions. + const nav = screen.getByText("popup.navigate").closest("a")!; + expect(nav).toHaveAttribute("href", expect.stringContaining("google.com/maps/dir/")); + // Show-on-map is an icon-only link to the Google Maps pin (search) endpoint. + const showOnMap = screen.getByLabelText("popup.showOnMap"); + expect(showOnMap).toHaveAttribute("href", expect.stringContaining("google.com/maps/search/")); + expect(showOnMap.getAttribute("href")).toContain("query=40.4168"); + // The new copy-link and share buttons exist (icon-only, aria-labelled). + expect(screen.getByLabelText("popup.copyLink")).toBeInTheDocument(); + expect(screen.getByLabelText("popup.share")).toBeInTheDocument(); + }); + + it("copy-link writes the station deep-link to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + // jsdom has no real origin/pathname assumptions; deep-link uses window.location. + render( {}} />); + (screen.getByLabelText("popup.copyLink") as HTMLButtonElement).click(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + const copied = writeText.mock.calls[0][0] as string; + expect(copied).toContain("station=ES%3A4710"); + expect(copied).toContain("lat=40.4168"); + expect(copied).toContain("lng=-3.7038"); + vi.unstubAllGlobals(); + }); }); diff --git a/src/components/map/station-popup.tsx b/src/components/map/station-popup.tsx index 581aa6a..b90e17d 100644 --- a/src/components/map/station-popup.tsx +++ b/src/components/map/station-popup.tsx @@ -6,7 +6,7 @@ import type { StationGeoJSON } from "@/types/station"; import { FUEL_TYPE_MAP } from "@/types/fuel"; import { useI18n } from "@/lib/i18n"; import { useCurrency, CURRENCIES } from "@/lib/currency"; -import { shareOrCopy } from "@/lib/share"; +import { shareOrCopy, copyToClipboard } from "@/lib/share"; import { buildStationQuery } from "@/lib/share-url"; interface StationPopupProps { @@ -33,6 +33,7 @@ export function StationPopup({ station, onClose }: StationPopupProps) { const { t } = useI18n(); const { decimals: userDecimals, rateInfo } = useCurrency(); const [copied, setCopied] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); const { properties, geometry } = station; // `properties.fuelType` is a raw string; the cast narrows it to the map key // type for the lookup. `.get()` returns undefined for unknown codes, which is @@ -50,25 +51,22 @@ export function StationPopup({ station, onClose }: StationPopupProps) { const lat = geometry.coordinates[1]; const lng = geometry.coordinates[0]; - // Compact price label reused in the share payload's text. - const priceLabel = - properties.price != null - ? `${properties.price.toFixed(displayDecimals)} ${displaySymbol}/L` - : t("popup.noPrice"); - - async function handleShare() { + // Absolute deep-link to this station (?station=CC:extId&lat&lng on the + // current locale path) — shared and copied by the action buttons below. + function shareUrl(): string { const sp = buildStationQuery({ country: properties.country ?? "", externalId: properties.externalId ?? "", lat, lng, }); - const url = `${window.location.origin}${window.location.pathname}?${sp}`; - const outcome = await shareOrCopy({ - title: properties.brand ?? "Pumperly", - text: `${properties.brand ?? ""} — ${priceLabel}`, - url, - }); + return `${window.location.origin}${window.location.pathname}?${sp}`; + } + + async function handleShare() { + // Share only { title, url }: a `text` field gets prepended to the URL by + // many native share targets, cluttering the shared link. + const outcome = await shareOrCopy({ title: properties.brand ?? "Pumperly", url: shareUrl() }); if (outcome === "copied") { setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -76,6 +74,13 @@ export function StationPopup({ station, onClose }: StationPopupProps) { // "shared" needs no UI; "dismissed"/"failed" are swallowed silently. } + async function handleCopyLink() { + if (await copyToClipboard(shareUrl())) { + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + } + } + return ( + {/* Copy link — copies the station deep-link directly to the clipboard + (no share sheet); icon swaps to a checkmark on success. */} + + {/* Share — Web Share API with clipboard fallback; copied state swaps the icon to a checkmark and is announced via aria-label/title. */}