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
22 changes: 22 additions & 0 deletions frontend/src/components/NetworkMismatchBanner.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,28 @@
gap: 12px;
}

.network-mismatch-banner__header {
display: flex;
align-items: center;
justify-content: space-between;
}

.network-mismatch-banner__dismiss {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #d32f2f;
padding: 0 4px;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s;
}

.network-mismatch-banner__dismiss:hover {
opacity: 1;
}

.network-mismatch-banner__title {
margin: 0;
font-size: 18px;
Expand Down
177 changes: 177 additions & 0 deletions frontend/src/components/NetworkMismatchBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import NetworkMismatchBanner from "./NetworkMismatchBanner";

// ---------------------------------------------------------------------------
// Mock useNetwork so tests control the mismatch state independently of the
// real Freighter / localStorage logic inside the hook.
// ---------------------------------------------------------------------------
const mockUseNetwork = vi.fn();

vi.mock("../hooks/useNetwork", () => ({
useNetwork: () => mockUseNetwork(),
}));

// Storage key used by the component (must match the constant in the source).
const DISMISS_STORAGE_KEY = "comebackhere-network-mismatch-dismissed";

function matchedNetworkState() {
return {
hasNetworkMismatch: false,
network: "testnet",
walletNetwork: "testnet",
isCheckingWallet: false,
};
}

function mismatchedNetworkState() {
return {
hasNetworkMismatch: true,
network: "testnet",
walletNetwork: "mainnet",
isCheckingWallet: false,
};
}

function checkingWalletState() {
return {
hasNetworkMismatch: false,
network: "testnet",
walletNetwork: null,
isCheckingWallet: true,
};
}

describe("NetworkMismatchBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});

// -------------------------------------------------------------------------
// Visibility
// -------------------------------------------------------------------------
describe("visibility", () => {
it("renders nothing when there is no network mismatch", () => {
mockUseNetwork.mockReturnValue(matchedNetworkState());
const { container } = render(<NetworkMismatchBanner />);
expect(container.firstChild).toBeNull();
});

it("renders nothing while the wallet network is still being checked", () => {
mockUseNetwork.mockReturnValue(checkingWalletState());
const { container } = render(<NetworkMismatchBanner />);
expect(container.firstChild).toBeNull();
});

it("renders the banner when there is a network mismatch", () => {
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
render(<NetworkMismatchBanner />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("Network Mismatch")).toBeInTheDocument();
});

it("shows app and wallet network names in the banner", () => {
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
render(<NetworkMismatchBanner />);
// The component renders each network name twice (in prose + in the
// details grid), so use getAllByText.
expect(screen.getAllByText("testnet").length).toBeGreaterThan(0);
expect(screen.getAllByText("mainnet").length).toBeGreaterThan(0);
});
});

// -------------------------------------------------------------------------
// Dismiss button
// -------------------------------------------------------------------------
describe("dismiss button", () => {
it("renders a dismiss button when the banner is visible", () => {
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
render(<NetworkMismatchBanner />);
expect(
screen.getByRole("button", { name: /dismiss network mismatch banner/i })
).toBeInTheDocument();
});

it("hides the banner immediately when the dismiss button is clicked", () => {
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
render(<NetworkMismatchBanner />);

fireEvent.click(
screen.getByRole("button", { name: /dismiss network mismatch banner/i })
);

expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});

// -------------------------------------------------------------------------
// Dismiss persistence via localStorage
// -------------------------------------------------------------------------
describe("dismiss persistence", () => {
it("persists the dismissed state to localStorage on dismiss", () => {
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
render(<NetworkMismatchBanner />);

fireEvent.click(
screen.getByRole("button", { name: /dismiss network mismatch banner/i })
);

expect(localStorage.getItem(DISMISS_STORAGE_KEY)).toBe("true");
});

it("does not show the banner on remount when already dismissed in localStorage", () => {
// Simulate a previously dismissed state (e.g. from a prior page load).
localStorage.setItem(DISMISS_STORAGE_KEY, "true");

mockUseNetwork.mockReturnValue(mismatchedNetworkState());
const { container } = render(<NetworkMismatchBanner />);

expect(container.firstChild).toBeNull();
});

it("uses the storage key that matches the network storage key constant", () => {
// The dismissed key should be distinct from the network selection key so
// clearing the dismissed flag cannot accidentally affect network selection.
expect(DISMISS_STORAGE_KEY).not.toBe("comebackhere-network");
});

it("clears the dismissed flag from localStorage when the mismatch resolves", () => {
// Start dismissed.
localStorage.setItem(DISMISS_STORAGE_KEY, "true");

// First render: mismatch is present but already dismissed.
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
const { rerender } = render(<NetworkMismatchBanner />);

// Simulate the mismatch resolving (user fixed their wallet).
mockUseNetwork.mockReturnValue(matchedNetworkState());
rerender(<NetworkMismatchBanner />);

expect(localStorage.getItem(DISMISS_STORAGE_KEY)).toBeNull();
});

it("re-shows the banner after the mismatch resolves and then reappears", () => {
// 1. Mismatch present → user dismisses.
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
const { rerender } = render(<NetworkMismatchBanner />);

fireEvent.click(
screen.getByRole("button", { name: /dismiss network mismatch banner/i })
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();

// 2. Mismatch resolves (clears dismissed flag).
mockUseNetwork.mockReturnValue(matchedNetworkState());
rerender(<NetworkMismatchBanner />);
expect(localStorage.getItem(DISMISS_STORAGE_KEY)).toBeNull();

// 3. Mismatch returns → banner should be visible again.
mockUseNetwork.mockReturnValue(mismatchedNetworkState());
rerender(<NetworkMismatchBanner />);

expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
});
49 changes: 47 additions & 2 deletions frontend/src/components/NetworkMismatchBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,62 @@
import { useState, useEffect } from "react";
import { useNetwork } from "../hooks/useNetwork";
import "./NetworkMismatchBanner.css";

const DISMISS_STORAGE_KEY = "comebackhere-network-mismatch-dismissed";

function getDismissedState(): boolean {
try {
return window.localStorage.getItem(DISMISS_STORAGE_KEY) === "true";
} catch {
return false;
}
}

export default function NetworkMismatchBanner() {
const { hasNetworkMismatch, network, walletNetwork, isCheckingWallet } = useNetwork();
const [dismissed, setDismissed] = useState<boolean>(getDismissedState);

if (isCheckingWallet || !hasNetworkMismatch) {
// Clear the dismissed flag whenever the mismatch condition changes (e.g. the
// user switches wallet network and then mismatches again on a new combo).
useEffect(() => {
if (!hasNetworkMismatch) {
// Mismatch is resolved – reset so the banner shows again if it returns.
try {
window.localStorage.removeItem(DISMISS_STORAGE_KEY);
} catch {
// ignore storage errors
}
setDismissed(false);
}
}, [hasNetworkMismatch]);

if (isCheckingWallet || !hasNetworkMismatch || dismissed) {
return null;
}

function handleDismiss() {
try {
window.localStorage.setItem(DISMISS_STORAGE_KEY, "true");
} catch {
// ignore storage errors
}
setDismissed(true);
}

return (
<div className="network-mismatch-banner" role="alert">
<div className="network-mismatch-banner__content">
<h3 className="network-mismatch-banner__title">Network Mismatch</h3>
<div className="network-mismatch-banner__header">
<h3 className="network-mismatch-banner__title">Network Mismatch</h3>
<button
className="network-mismatch-banner__dismiss"
onClick={handleDismiss}
aria-label="Dismiss network mismatch banner"
type="button"
>
</button>
</div>
<p className="network-mismatch-banner__message">
Your wallet is connected to <strong>{walletNetwork}</strong>, but COMEBACKHERE is
configured for <strong>{network}</strong>. Please switch your wallet network or change the
Expand Down