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
67 changes: 67 additions & 0 deletions e2e/tools-url.spec.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
45 changes: 37 additions & 8 deletions src/app/[locale]/tools/ToolTabs.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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;
Expand All @@ -66,7 +81,20 @@ export interface GeneratedConfig {
}

export default function ToolTabs() {
const [active, setActive] = useState<TabId>("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)!;

Expand All @@ -79,7 +107,8 @@ export default function ToolTabs() {
return (
<button
key={tab.id}
onClick={() => setActive(tab.id)}
onClick={() => selectTab(tab.id)}
aria-current={isActive ? "page" : undefined}
className={`
flex-1 relative py-2.5 sm:py-3 rounded-lg text-[13px] sm:text-sm font-semibold
transition-all duration-200 ease-out
Expand Down Expand Up @@ -115,9 +144,9 @@ export default function ToolTabs() {
{/* Card body */}
<div className="px-5 py-6 sm:px-7 sm:py-7">
{active === "converter" && <ZecToZatsConverter />}
{active === "payment" && <PaymentRequestBuilder />}
{active === "payment-request" && <PaymentRequestBuilder />}
{active === "payment-request-widget" && <PaymentRequestWidget />}
{active === "decoder" && <AddressDecoder />}
{active === "address-decoder" && <AddressDecoder />}
</div>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/app/[locale]/tools/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import ToolTabs from './ToolTabs'

// Which tool is open comes from `?tool=`, so this page can't be prerendered as
// one static document — rendering per request is what lets a shared link land
// on the right tool instead of flipping to it after hydration.
export const dynamic = 'force-dynamic'

export const metadata = {
title: 'Zcash Developer Tools | ZecHub',
description:
Expand Down
Loading