diff --git a/e2e/tools-url.spec.ts b/e2e/tools-url.spec.ts new file mode 100644 index 000000000..1deb0103c --- /dev/null +++ b/e2e/tools-url.spec.ts @@ -0,0 +1,67 @@ +import { expect, Page, test } from "@playwright/test"; + +// Guards the addressable-tool URLs on /tools: every tool has to be reachable by +// link, survive a refresh, and move with Back/Forward. Needs a server running +// (`yarn dev`, or `yarn build && yarn start`). +const BASE = process.env.TOOLS_BASE_URL || "http://localhost:3000"; + +const heading = (page: Page) => page.locator("h2").first(); +const tab = (page: Page, name: string) => + page.getByRole("button", { name, exact: true }); + +// The tab bar only responds once React has hydrated, so retry the click until +// the URL actually changes rather than racing the first paint. +async function clickTab(page: Page, name: string, slug: string) { + await expect(async () => { + await tab(page, name).click(); + await expect(page).toHaveURL(new RegExp(`\\?tool=${slug}$`)); + }).toPass({ timeout: 30_000 }); +} + +test("a tool can be linked to directly", async ({ page }) => { + await page.goto(`${BASE}/tools?tool=address-decoder`); + await expect(heading(page)).toHaveText("Address Decoder"); + + await page.goto(`${BASE}/tools?tool=payment-request`); + await expect(heading(page)).toHaveText("Payment Request Builder"); +}); + +test("picking a tool puts its slug in the url", async ({ page }) => { + await page.goto(`${BASE}/tools`); + await expect(heading(page)).toHaveText("ZEC ↔ Zats"); + + await clickTab(page, "Payment Request", "payment-request"); + await expect(heading(page)).toHaveText("Payment Request Builder"); +}); + +test("a refresh stays on the same tool", async ({ page }) => { + await page.goto(`${BASE}/tools`); + await clickTab(page, "Address Decoder", "address-decoder"); + + await page.reload(); + await expect(heading(page)).toHaveText("Address Decoder"); +}); + +test("back and forward walk through the tools", async ({ page }) => { + await page.goto(`${BASE}/tools`); + await clickTab(page, "Payment Request", "payment-request"); + await clickTab(page, "Address Decoder", "address-decoder"); + + await page.goBack(); + await expect(heading(page)).toHaveText("Payment Request Builder"); + await page.goBack(); + await expect(heading(page)).toHaveText("ZEC ↔ Zats"); + await page.goForward(); + await expect(heading(page)).toHaveText("Payment Request Builder"); +}); + +test("an unknown slug falls back to the first tool", async ({ page }) => { + await page.goto(`${BASE}/tools?tool=not-a-real-tool`); + await expect(heading(page)).toHaveText("ZEC ↔ Zats"); +}); + +test("the locale prefix survives a tool switch", async ({ page }) => { + await page.goto(`${BASE}/es/tools`); + await clickTab(page, "Address Decoder", "address-decoder"); + await expect(page).toHaveURL(`${BASE}/es/tools?tool=address-decoder`); +}); diff --git a/src/app/[locale]/tools/ToolTabs.tsx b/src/app/[locale]/tools/ToolTabs.tsx index fd9e5f23d..95bd29e64 100644 --- a/src/app/[locale]/tools/ToolTabs.tsx +++ b/src/app/[locale]/tools/ToolTabs.tsx @@ -1,12 +1,18 @@ "use client"; import ZecToZatsConverter from "@/components/Converter/ZecToZatsConverter"; -import { useState } from "react"; +import { useSearchParams } from "next/navigation"; import AddressDecoder from "./AddressDecoder"; import PaymentRequestBuilder from "./PaymentRequestBuilder"; import PaymentRequestWidget from "./zcash-payment-widget/PaymentRequestWidget"; -type TabId = "converter" | "payment" | "decoder" | "payment-request-widget"; +// Tab ids double as the public URL slug, e.g. /tools?tool=address-decoder. +// Renaming one changes a shareable link, so treat them as part of the API. +type TabId = + | "converter" + | "payment-request" + | "payment-request-widget" + | "address-decoder"; interface Tab { id: TabId; @@ -27,7 +33,7 @@ const TABS: Tab[] = [ subtitle: "Precise conversion between ZEC and Zatoshi", }, { - id: "payment", + id: "payment-request", label: "Payment Request", shortLabel: "Payment", badge: "ZIP-321", @@ -43,7 +49,7 @@ const TABS: Tab[] = [ subtitle: "Generate zcash: URIs with QR codes for easy payment requests", }, { - id: "decoder", + id: "address-decoder", label: "Address Decoder", shortLabel: "Decoder", badge: "Unified Address", @@ -52,6 +58,15 @@ const TABS: Tab[] = [ }, ]; +const TOOL_PARAM = "tool"; +const DEFAULT_TAB: TabId = TABS[0].id; + +function tabIdFromParam(requested: string | null): TabId { + return TABS.some((t) => t.id === requested) + ? (requested as TabId) + : DEFAULT_TAB; +} + export interface GeneratedConfig { address: string; amount: number; @@ -66,7 +81,20 @@ export interface GeneratedConfig { } export default function ToolTabs() { - const [active, setActive] = useState("converter"); + // `?tool=` is the only source of truth for which tool is open: it makes each + // one linkable, survives a refresh, and moves with Back/Forward for free. + const searchParams = useSearchParams(); + const active = tabIdFromParam(searchParams?.get(TOOL_PARAM) ?? null); + + const selectTab = (id: TabId) => { + if (id === active) return; + const url = new URL(window.location.href); + url.searchParams.set(TOOL_PARAM, id); + // pushState rather than router.push: switching tools stays instant and + // local instead of round-tripping to the server, and Back/Forward still get + // a real history entry. Next keeps useSearchParams in sync with it. + window.history.pushState(null, "", url); + }; const current = TABS.find((t) => t.id === active)!; @@ -79,7 +107,8 @@ export default function ToolTabs() { return (