Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/components/map/station-popup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<StationPopup station={makeStation()} onClose={() => {}} />);
// 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(<StationPopup station={makeStation({ externalId: "4710", country: "ES" })} onClose={() => {}} />);
(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();
});
});
53 changes: 39 additions & 14 deletions src/components/map/station-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -50,32 +51,36 @@ 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);
}
// "shared" needs no UI; "dismissed"/"failed" are swallowed silently.
}

async function handleCopyLink() {
if (await copyToClipboard(shareUrl())) {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
}
}

return (
<Popup
longitude={geometry.coordinates[0]}
Expand Down Expand Up @@ -178,6 +183,26 @@ export function StationPopup({ station, onClose }: StationPopupProps) {
</svg>
</a>

{/* Copy link — copies the station deep-link directly to the clipboard
(no share sheet); icon swaps to a checkmark on success. */}
<button
type="button"
onClick={handleCopyLink}
aria-label={linkCopied ? t("popup.copied") : t("popup.copyLink")}
title={linkCopied ? t("popup.copied") : t("popup.copyLink")}
className="flex shrink-0 items-center justify-center rounded-lg bg-gray-100 px-3 py-2 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
{linkCopied ? (
<svg className="h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
)}
</button>

{/* Share — Web Share API with clipboard fallback; copied state swaps
the icon to a checkmark and is announced via aria-label/title. */}
<button
Expand Down
4 changes: 3 additions & 1 deletion src/components/search/search-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,9 @@ export function SearchPanel({
fuel: selectedFuel ?? "",
}).toString();
const url = `${window.location.origin}${window.location.pathname}?${qs}`;
const outcome = await shareOrCopy({ title: t("share.routeTitle"), text: t("share.routeTitle"), url });
// 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 });
if (outcome === "copied") {
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2000);
Expand Down
16 changes: 16 additions & 0 deletions src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Ver en el mapa",
"popup.share": "Compartir",
"popup.copied": "¡Copiado!",
"popup.copyLink": "Copiar enlace",
"share.point": "Punto compartido",
"share.routeTitle": "Ruta en Pumperly",
"share.shareRoute": "Compartir ruta",
Expand Down Expand Up @@ -137,6 +138,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Show on map",
"popup.share": "Share",
"popup.copied": "Copied!",
"popup.copyLink": "Copy link",
"share.point": "Shared point",
"share.routeTitle": "Pumperly route",
"share.shareRoute": "Share route",
Expand Down Expand Up @@ -196,6 +198,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Voir sur la carte",
"popup.share": "Partager",
"popup.copied": "Copié !",
"popup.copyLink": "Copier le lien",
"share.point": "Point partagé",
"share.routeTitle": "Itinéraire Pumperly",
"share.shareRoute": "Partager l’itinéraire",
Expand Down Expand Up @@ -255,6 +258,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Auf Karte zeigen",
"popup.share": "Teilen",
"popup.copied": "Kopiert!",
"popup.copyLink": "Link kopieren",
"share.point": "Geteilter Punkt",
"share.routeTitle": "Pumperly-Route",
"share.shareRoute": "Route teilen",
Expand Down Expand Up @@ -314,6 +318,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Mostra sulla mappa",
"popup.share": "Condividi",
"popup.copied": "Copiato!",
"popup.copyLink": "Copia link",
"share.point": "Punto condiviso",
"share.routeTitle": "Percorso Pumperly",
"share.shareRoute": "Condividi percorso",
Expand Down Expand Up @@ -373,6 +378,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Ver no mapa",
"popup.share": "Partilhar",
"popup.copied": "Copiado!",
"popup.copyLink": "Copiar ligação",
"share.point": "Ponto partilhado",
"share.routeTitle": "Rota Pumperly",
"share.shareRoute": "Partilhar rota",
Expand Down Expand Up @@ -432,6 +438,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Pokaż na mapie",
"popup.share": "Udostępnij",
"popup.copied": "Skopiowano!",
"popup.copyLink": "Kopiuj link",
"share.point": "Udostępniony punkt",
"share.routeTitle": "Trasa Pumperly",
"share.shareRoute": "Udostępnij trasę",
Expand Down Expand Up @@ -491,6 +498,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Zobrazit na mapě",
"popup.share": "Sdílet",
"popup.copied": "Zkopírováno!",
"popup.copyLink": "Kopírovat odkaz",
"share.point": "Sdílený bod",
"share.routeTitle": "Trasa Pumperly",
"share.shareRoute": "Sdílet trasu",
Expand Down Expand Up @@ -550,6 +558,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Mutasd a térképen",
"popup.share": "Megosztás",
"popup.copied": "Másolva!",
"popup.copyLink": "Hivatkozás másolása",
"share.point": "Megosztott pont",
"share.routeTitle": "Pumperly útvonal",
"share.shareRoute": "Útvonal megosztása",
Expand Down Expand Up @@ -609,6 +618,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Покажи на картата",
"popup.share": "Сподели",
"popup.copied": "Копирано!",
"popup.copyLink": "Копирай връзка",
"share.point": "Споделена точка",
"share.routeTitle": "Маршрут в Pumperly",
"share.shareRoute": "Споделяне на маршрут",
Expand Down Expand Up @@ -668,6 +678,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Zobraziť na mape",
"popup.share": "Zdieľať",
"popup.copied": "Skopírované!",
"popup.copyLink": "Kopírovať odkaz",
"share.point": "Zdieľaný bod",
"share.routeTitle": "Trasa Pumperly",
"share.shareRoute": "Zdieľať trasu",
Expand Down Expand Up @@ -727,6 +738,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Vis på kort",
"popup.share": "Del",
"popup.copied": "Kopieret!",
"popup.copyLink": "Kopiér link",
"share.point": "Delt punkt",
"share.routeTitle": "Pumperly-rute",
"share.shareRoute": "Del rute",
Expand Down Expand Up @@ -786,6 +798,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Visa på kartan",
"popup.share": "Dela",
"popup.copied": "Kopierat!",
"popup.copyLink": "Kopiera länk",
"share.point": "Delad punkt",
"share.routeTitle": "Pumperly-rutt",
"share.shareRoute": "Dela rutt",
Expand Down Expand Up @@ -845,6 +858,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Vis på kart",
"popup.share": "Del",
"popup.copied": "Kopiert!",
"popup.copyLink": "Kopier lenke",
"share.point": "Delt punkt",
"share.routeTitle": "Pumperly-rute",
"share.shareRoute": "Del rute",
Expand Down Expand Up @@ -904,6 +918,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Prikaži na mapi",
"popup.share": "Podeli",
"popup.copied": "Kopirano!",
"popup.copyLink": "Kopiraj link",
"share.point": "Deljena tačka",
"share.routeTitle": "Pumperly ruta",
"share.shareRoute": "Podeli rutu",
Expand Down Expand Up @@ -963,6 +978,7 @@ const translations: Record<Locale, Record<string, string>> = {
"popup.showOnMap": "Näytä kartalla",
"popup.share": "Jaa",
"popup.copied": "Kopioitu!",
"popup.copyLink": "Kopioi linkki",
"share.point": "Jaettu piste",
"share.routeTitle": "Pumperly-reitti",
"share.shareRoute": "Jaa reitti",
Expand Down
7 changes: 6 additions & 1 deletion src/lib/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ function legacyCopy(text: string): boolean {
}
}

async function copyToClipboard(text: string): Promise<boolean> {
/**
* Copy text to the clipboard: native Clipboard API first, legacy execCommand
* fallback. Returns whether the copy succeeded. Use directly for a "copy link"
* action (no share sheet).
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
Expand Down