Skip to content
Closed
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
88 changes: 88 additions & 0 deletions frontend/src/components/NetworkMismatchGuideModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal";

function renderModal(overrides: Partial<React.ComponentProps<typeof NetworkMismatchGuideModal>> = {}) {
const onClose = vi.fn();
const onCheckNow = vi.fn();
const utils = render(
<NetworkMismatchGuideModal
isOpen
onClose={onClose}
isMismatch
isChecking={false}
walletNetwork="Mainnet"
expectedNetwork="Testnet"
onCheckNow={onCheckNow}
{...overrides}
/>,
);
return { ...utils, onClose, onCheckNow };
}

describe("NetworkMismatchGuideModal", () => {
it("renders nothing when closed", () => {
renderModal({ isOpen: false });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("renders the guided steps and current/expected networks when open", () => {
renderModal();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/switch your wallet's network/i)).toBeInTheDocument();
expect(screen.getByText(/select testnet from the network list/i)).toBeInTheDocument();
});

it("calls onCheckNow when the check-again button is clicked", () => {
const { onCheckNow } = renderModal();
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
expect(onCheckNow).toHaveBeenCalledTimes(1);
});

it("shows a still-mismatched message after a check that doesn't resolve it", () => {
const { rerender } = renderModal();
fireEvent.click(screen.getByRole("button", { name: /check again/i }));

// Simulate the check completing without resolving the mismatch.
rerender(
<NetworkMismatchGuideModal
isOpen
onClose={vi.fn()}
isMismatch
isChecking={false}
walletNetwork="Mainnet"
expectedNetwork="Testnet"
onCheckNow={vi.fn()}
/>,
);
expect(screen.getByText(/still on mainnet/i)).toBeInTheDocument();
});

it("auto-closes once a check confirms the mismatch is resolved", async () => {
const onClose = vi.fn();
const { rerender } = renderModal({ onClose });
fireEvent.click(screen.getByRole("button", { name: /check again/i }));

// Simulate the check completing and finding the mismatch resolved.
rerender(
<NetworkMismatchGuideModal
isOpen
onClose={onClose}
isMismatch={false}
isChecking={false}
walletNetwork="Testnet"
expectedNetwork="Testnet"
onCheckNow={vi.fn()}
/>,
);

await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});

it("does not auto-close on first render just because isMismatch is already false", () => {
const onClose = vi.fn();
renderModal({ onClose, isMismatch: false });
expect(onClose).not.toHaveBeenCalled();
});
});
161 changes: 161 additions & 0 deletions frontend/src/components/NetworkMismatchGuideModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useEffect, useRef, useState } from "react";
import { CheckCircle } from "lucide-react";
import { AlertTriangle, RefreshCw } from "./icons";
import { Modal } from "./Modal";
import { useTranslation } from "../i18n";

interface NetworkMismatchGuideModalProps {
isOpen: boolean;
onClose: () => void;
isMismatch: boolean;
isChecking: boolean;
walletNetwork: string | null;
expectedNetwork: string;
onCheckNow: () => void;
}

/**
* Step-by-step guided fix flow for a wallet/app network mismatch. Rendered
* from a "Show me how to fix this" action on the persistent warning banner.
* Closes itself once a recheck confirms the wallet is on the expected
* network, so the user gets a clear "you're all set" moment instead of just
* having the banner silently disappear.
*/
const NetworkMismatchGuideModal: React.FC<NetworkMismatchGuideModalProps> = ({
isOpen,
onClose,
isMismatch,
isChecking,
walletNetwork,
expectedNetwork,
onCheckNow,
}) => {
const { t } = useTranslation();
const [hasChecked, setHasChecked] = useState(false);
const wasOpenRef = useRef(false);

useEffect(() => {
if (isOpen && !wasOpenRef.current) {
setHasChecked(false);
}
wasOpenRef.current = isOpen;
}, [isOpen]);

useEffect(() => {
if (isOpen && hasChecked && !isChecking && !isMismatch) {
onClose();
}
}, [isOpen, hasChecked, isChecking, isMismatch, onClose]);

if (!isOpen) return null;

const handleCheckAgain = () => {
setHasChecked(true);
onCheckNow();
};

return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="sm"
aria-labelledby="network-guide-title"
aria-describedby="network-guide-desc"
>
<div style={{ textAlign: "center" }}>
<div
style={{
background: "rgba(220, 38, 38, 0.1)",
color: "rgb(220, 38, 38)",
padding: "16px",
borderRadius: "50%",
display: "inline-flex",
marginBottom: "16px",
}}
>
<AlertTriangle size={32} />
</div>

<h2 id="network-guide-title" style={{ margin: "0 0 12px", fontSize: "1.35rem" }}>
{t("networkWarning.guide.title")}
</h2>
<p
id="network-guide-desc"
style={{ color: "var(--text-secondary)", margin: "0 0 20px", lineHeight: 1.6 }}
>
{t("networkWarning.guide.description")
.replace("{{wallet}}", walletNetwork ?? expectedNetwork)
.replace("{{expected}}", expectedNetwork)}
</p>

<ol
style={{
textAlign: "left",
margin: "0 0 20px",
padding: "0 0 0 20px",
color: "var(--text-primary)",
lineHeight: 1.9,
}}
>
<li>{t("networkWarning.guide.step1")}</li>
<li>{t("networkWarning.guide.step2")}</li>
<li>{t("networkWarning.guide.step3").replace("{{expected}}", expectedNetwork)}</li>
</ol>

{hasChecked && !isChecking && isMismatch && (
<p
role="status"
style={{
color: "rgb(220, 38, 38)",
fontSize: "0.875rem",
margin: "0 0 16px",
}}
>
{t("networkWarning.guide.stillMismatched").replace(
"{{wallet}}",
walletNetwork ?? expectedNetwork,
)}
</p>
)}

{hasChecked && !isChecking && !isMismatch && (
<p
role="status"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
color: "rgb(34, 197, 94)",
fontSize: "0.875rem",
margin: "0 0 16px",
}}
>
<CheckCircle size={16} />
{t("networkWarning.guide.resolved").replace("{{expected}}", expectedNetwork)}
</p>
)}

<button
type="button"
className="btn btn-primary"
onClick={handleCheckAgain}
disabled={isChecking}
style={{
width: "100%",
padding: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
}}
>
<RefreshCw size={16} className={isChecking ? "spin" : undefined} />
{isChecking ? t("networkWarning.guide.checking") : t("networkWarning.guide.checkAgain")}
</button>
</div>
</Modal>
);
};

export default NetworkMismatchGuideModal;
100 changes: 66 additions & 34 deletions frontend/src/components/NetworkWarningBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,83 @@
import React from "react";
import React, { useState } from "react";
import { AlertTriangle } from "./icons";
import { useWalletNetwork } from "../hooks/useWalletNetwork";
import { useTranslation } from "../i18n";
import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal";

interface NetworkWarningBannerProps {
walletAddress: string | null;
}

const NetworkWarningBanner: React.FC<NetworkWarningBannerProps> = ({ walletAddress }) => {
const { isMismatch, walletNetwork, expectedNetwork } = useWalletNetwork(walletAddress);
const { isMismatch, walletNetwork, expectedNetwork, isChecking, checkNow } =
useWalletNetwork(walletAddress);
const { t } = useTranslation();
const [isGuideOpen, setIsGuideOpen] = useState(false);

if (!isMismatch) return null;

return (
<div
role="alert"
aria-live="assertive"
style={{
position: "fixed",
top: "72px",
left: 0,
right: 0,
zIndex: 200,
background: "rgba(220, 38, 38, 0.95)",
borderBottom: "1px solid rgba(255, 100, 100, 0.5)",
backdropFilter: "blur(8px)",
color: "#fff",
padding: "10px 24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
fontSize: "0.875rem",
lineHeight: "1.5",
}}
>
<AlertTriangle size={18} style={{ flexShrink: 0 }} />
<span>
<strong>{t("networkWarning.wrongNetwork")}</strong>{" "}
{t("networkWarning.walletOn")}{" "}
<strong>{walletNetwork}</strong>,{" "}
{t("networkWarning.appRequires")}{" "}
<strong>{expectedNetwork}</strong>.{" "}
{t("networkWarning.switchInstructions").replace("{{network}}", expectedNetwork ?? "")}
</span>
</div>
<>
<div
role="alert"
aria-live="assertive"
style={{
position: "fixed",
top: "72px",
left: 0,
right: 0,
zIndex: 200,
background: "rgba(220, 38, 38, 0.95)",
borderBottom: "1px solid rgba(255, 100, 100, 0.5)",
backdropFilter: "blur(8px)",
color: "#fff",
padding: "10px 24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: "10px",
fontSize: "0.875rem",
lineHeight: "1.5",
}}
>
<AlertTriangle size={18} style={{ flexShrink: 0 }} />
<span>
<strong>{t("networkWarning.wrongNetwork")}</strong>{" "}
{t("networkWarning.walletOn")}{" "}
<strong>{walletNetwork}</strong>,{" "}
{t("networkWarning.appRequires")}{" "}
<strong>{expectedNetwork}</strong>.{" "}
{t("networkWarning.switchInstructions").replace("{{network}}", expectedNetwork ?? "")}
</span>
<button
type="button"
onClick={() => setIsGuideOpen(true)}
style={{
background: "rgba(255, 255, 255, 0.15)",
border: "1px solid rgba(255, 255, 255, 0.4)",
borderRadius: "6px",
color: "#fff",
padding: "4px 10px",
fontSize: "0.8rem",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
}}
>
{t("networkWarning.fixNow")}
</button>
</div>
<NetworkMismatchGuideModal
isOpen={isGuideOpen}
onClose={() => setIsGuideOpen(false)}
isMismatch={isMismatch}
isChecking={isChecking}
walletNetwork={walletNetwork}
expectedNetwork={expectedNetwork}
onCheckNow={checkNow}
/>
</>
);
};

Expand Down
Loading
Loading