Skip to content

Commit 7d9916e

Browse files
feat: add an interactive guided-fix flow for network mismatches
The existing NetworkWarningBanner (#608) already detects a wallet/app network mismatch and shows static switch instructions, but there was no interactive way to act on it: the user had to manually switch networks in Freighter and wait up to 10s for the next background poll to notice. Add a "Show me how to fix this" action on the banner that opens a guided NetworkMismatchGuideModal: numbered steps to switch networks in Freighter, and a "check again" button that calls a new checkNow() escape hatch on useWalletNetwork to re-read the network immediately instead of waiting for the poll interval. The modal reports back whether the switch worked and auto-closes once the mismatch is confirmed resolved, instead of just having the banner silently disappear. Adds i18n strings (en/es) for the guide, and test coverage for useWalletNetwork and the new modal — neither had tests before. Related to #1038, which duplicates #(network-mismatch-warning), already resolved by #608 for the detector/banner half of this.
1 parent 3f74507 commit 7d9916e

7 files changed

Lines changed: 471 additions & 53 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React from "react";
2+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+
import { describe, it, expect, vi } from "vitest";
4+
import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal";
5+
6+
function renderModal(overrides: Partial<React.ComponentProps<typeof NetworkMismatchGuideModal>> = {}) {
7+
const onClose = vi.fn();
8+
const onCheckNow = vi.fn();
9+
const utils = render(
10+
<NetworkMismatchGuideModal
11+
isOpen
12+
onClose={onClose}
13+
isMismatch
14+
isChecking={false}
15+
walletNetwork="Mainnet"
16+
expectedNetwork="Testnet"
17+
onCheckNow={onCheckNow}
18+
{...overrides}
19+
/>,
20+
);
21+
return { ...utils, onClose, onCheckNow };
22+
}
23+
24+
describe("NetworkMismatchGuideModal", () => {
25+
it("renders nothing when closed", () => {
26+
renderModal({ isOpen: false });
27+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
28+
});
29+
30+
it("renders the guided steps and current/expected networks when open", () => {
31+
renderModal();
32+
expect(screen.getByRole("dialog")).toBeInTheDocument();
33+
expect(screen.getByText(/switch your wallet's network/i)).toBeInTheDocument();
34+
expect(screen.getByText(/select testnet from the network list/i)).toBeInTheDocument();
35+
});
36+
37+
it("calls onCheckNow when the check-again button is clicked", () => {
38+
const { onCheckNow } = renderModal();
39+
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
40+
expect(onCheckNow).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it("shows a still-mismatched message after a check that doesn't resolve it", () => {
44+
const { rerender } = renderModal();
45+
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
46+
47+
// Simulate the check completing without resolving the mismatch.
48+
rerender(
49+
<NetworkMismatchGuideModal
50+
isOpen
51+
onClose={vi.fn()}
52+
isMismatch
53+
isChecking={false}
54+
walletNetwork="Mainnet"
55+
expectedNetwork="Testnet"
56+
onCheckNow={vi.fn()}
57+
/>,
58+
);
59+
expect(screen.getByText(/still on mainnet/i)).toBeInTheDocument();
60+
});
61+
62+
it("auto-closes once a check confirms the mismatch is resolved", async () => {
63+
const onClose = vi.fn();
64+
const { rerender } = renderModal({ onClose });
65+
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
66+
67+
// Simulate the check completing and finding the mismatch resolved.
68+
rerender(
69+
<NetworkMismatchGuideModal
70+
isOpen
71+
onClose={onClose}
72+
isMismatch={false}
73+
isChecking={false}
74+
walletNetwork="Testnet"
75+
expectedNetwork="Testnet"
76+
onCheckNow={vi.fn()}
77+
/>,
78+
);
79+
80+
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
81+
});
82+
83+
it("does not auto-close on first render just because isMismatch is already false", () => {
84+
const onClose = vi.fn();
85+
renderModal({ onClose, isMismatch: false });
86+
expect(onClose).not.toHaveBeenCalled();
87+
});
88+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { CheckCircle } from "lucide-react";
3+
import { AlertTriangle, RefreshCw } from "./icons";
4+
import { Modal } from "./Modal";
5+
import { useTranslation } from "../i18n";
6+
7+
interface NetworkMismatchGuideModalProps {
8+
isOpen: boolean;
9+
onClose: () => void;
10+
isMismatch: boolean;
11+
isChecking: boolean;
12+
walletNetwork: string | null;
13+
expectedNetwork: string;
14+
onCheckNow: () => void;
15+
}
16+
17+
/**
18+
* Step-by-step guided fix flow for a wallet/app network mismatch. Rendered
19+
* from a "Show me how to fix this" action on the persistent warning banner.
20+
* Closes itself once a recheck confirms the wallet is on the expected
21+
* network, so the user gets a clear "you're all set" moment instead of just
22+
* having the banner silently disappear.
23+
*/
24+
const NetworkMismatchGuideModal: React.FC<NetworkMismatchGuideModalProps> = ({
25+
isOpen,
26+
onClose,
27+
isMismatch,
28+
isChecking,
29+
walletNetwork,
30+
expectedNetwork,
31+
onCheckNow,
32+
}) => {
33+
const { t } = useTranslation();
34+
const [hasChecked, setHasChecked] = useState(false);
35+
const wasOpenRef = useRef(false);
36+
37+
useEffect(() => {
38+
if (isOpen && !wasOpenRef.current) {
39+
setHasChecked(false);
40+
}
41+
wasOpenRef.current = isOpen;
42+
}, [isOpen]);
43+
44+
useEffect(() => {
45+
if (isOpen && hasChecked && !isChecking && !isMismatch) {
46+
onClose();
47+
}
48+
}, [isOpen, hasChecked, isChecking, isMismatch, onClose]);
49+
50+
if (!isOpen) return null;
51+
52+
const handleCheckAgain = () => {
53+
setHasChecked(true);
54+
onCheckNow();
55+
};
56+
57+
return (
58+
<Modal
59+
isOpen={isOpen}
60+
onClose={onClose}
61+
size="sm"
62+
aria-labelledby="network-guide-title"
63+
aria-describedby="network-guide-desc"
64+
>
65+
<div style={{ textAlign: "center" }}>
66+
<div
67+
style={{
68+
background: "rgba(220, 38, 38, 0.1)",
69+
color: "rgb(220, 38, 38)",
70+
padding: "16px",
71+
borderRadius: "50%",
72+
display: "inline-flex",
73+
marginBottom: "16px",
74+
}}
75+
>
76+
<AlertTriangle size={32} />
77+
</div>
78+
79+
<h2 id="network-guide-title" style={{ margin: "0 0 12px", fontSize: "1.35rem" }}>
80+
{t("networkWarning.guide.title")}
81+
</h2>
82+
<p
83+
id="network-guide-desc"
84+
style={{ color: "var(--text-secondary)", margin: "0 0 20px", lineHeight: 1.6 }}
85+
>
86+
{t("networkWarning.guide.description")
87+
.replace("{{wallet}}", walletNetwork ?? expectedNetwork)
88+
.replace("{{expected}}", expectedNetwork)}
89+
</p>
90+
91+
<ol
92+
style={{
93+
textAlign: "left",
94+
margin: "0 0 20px",
95+
padding: "0 0 0 20px",
96+
color: "var(--text-primary)",
97+
lineHeight: 1.9,
98+
}}
99+
>
100+
<li>{t("networkWarning.guide.step1")}</li>
101+
<li>{t("networkWarning.guide.step2")}</li>
102+
<li>{t("networkWarning.guide.step3").replace("{{expected}}", expectedNetwork)}</li>
103+
</ol>
104+
105+
{hasChecked && !isChecking && isMismatch && (
106+
<p
107+
role="status"
108+
style={{
109+
color: "rgb(220, 38, 38)",
110+
fontSize: "0.875rem",
111+
margin: "0 0 16px",
112+
}}
113+
>
114+
{t("networkWarning.guide.stillMismatched").replace(
115+
"{{wallet}}",
116+
walletNetwork ?? expectedNetwork,
117+
)}
118+
</p>
119+
)}
120+
121+
{hasChecked && !isChecking && !isMismatch && (
122+
<p
123+
role="status"
124+
style={{
125+
display: "flex",
126+
alignItems: "center",
127+
justifyContent: "center",
128+
gap: "8px",
129+
color: "rgb(34, 197, 94)",
130+
fontSize: "0.875rem",
131+
margin: "0 0 16px",
132+
}}
133+
>
134+
<CheckCircle size={16} />
135+
{t("networkWarning.guide.resolved").replace("{{expected}}", expectedNetwork)}
136+
</p>
137+
)}
138+
139+
<button
140+
type="button"
141+
className="btn btn-primary"
142+
onClick={handleCheckAgain}
143+
disabled={isChecking}
144+
style={{
145+
width: "100%",
146+
padding: "14px",
147+
display: "flex",
148+
alignItems: "center",
149+
justifyContent: "center",
150+
gap: "8px",
151+
}}
152+
>
153+
<RefreshCw size={16} className={isChecking ? "spin" : undefined} />
154+
{isChecking ? t("networkWarning.guide.checking") : t("networkWarning.guide.checkAgain")}
155+
</button>
156+
</div>
157+
</Modal>
158+
);
159+
};
160+
161+
export default NetworkMismatchGuideModal;

frontend/src/components/NetworkWarningBanner.tsx

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,83 @@
1-
import React from "react";
1+
import React, { useState } from "react";
22
import { AlertTriangle } from "./icons";
33
import { useWalletNetwork } from "../hooks/useWalletNetwork";
44
import { useTranslation } from "../i18n";
5+
import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal";
56

67
interface NetworkWarningBannerProps {
78
walletAddress: string | null;
89
}
910

1011
const NetworkWarningBanner: React.FC<NetworkWarningBannerProps> = ({ walletAddress }) => {
11-
const { isMismatch, walletNetwork, expectedNetwork } = useWalletNetwork(walletAddress);
12+
const { isMismatch, walletNetwork, expectedNetwork, isChecking, checkNow } =
13+
useWalletNetwork(walletAddress);
1214
const { t } = useTranslation();
15+
const [isGuideOpen, setIsGuideOpen] = useState(false);
1316

1417
if (!isMismatch) return null;
1518

1619
return (
17-
<div
18-
role="alert"
19-
aria-live="assertive"
20-
style={{
21-
position: "fixed",
22-
top: "72px",
23-
left: 0,
24-
right: 0,
25-
zIndex: 200,
26-
background: "rgba(220, 38, 38, 0.95)",
27-
borderBottom: "1px solid rgba(255, 100, 100, 0.5)",
28-
backdropFilter: "blur(8px)",
29-
color: "#fff",
30-
padding: "10px 24px",
31-
display: "flex",
32-
alignItems: "center",
33-
justifyContent: "center",
34-
gap: "10px",
35-
fontSize: "0.875rem",
36-
lineHeight: "1.5",
37-
}}
38-
>
39-
<AlertTriangle size={18} style={{ flexShrink: 0 }} />
40-
<span>
41-
<strong>{t("networkWarning.wrongNetwork")}</strong>{" "}
42-
{t("networkWarning.walletOn")}{" "}
43-
<strong>{walletNetwork}</strong>,{" "}
44-
{t("networkWarning.appRequires")}{" "}
45-
<strong>{expectedNetwork}</strong>.{" "}
46-
{t("networkWarning.switchInstructions").replace("{{network}}", expectedNetwork ?? "")}
47-
</span>
48-
</div>
20+
<>
21+
<div
22+
role="alert"
23+
aria-live="assertive"
24+
style={{
25+
position: "fixed",
26+
top: "72px",
27+
left: 0,
28+
right: 0,
29+
zIndex: 200,
30+
background: "rgba(220, 38, 38, 0.95)",
31+
borderBottom: "1px solid rgba(255, 100, 100, 0.5)",
32+
backdropFilter: "blur(8px)",
33+
color: "#fff",
34+
padding: "10px 24px",
35+
display: "flex",
36+
alignItems: "center",
37+
justifyContent: "center",
38+
flexWrap: "wrap",
39+
gap: "10px",
40+
fontSize: "0.875rem",
41+
lineHeight: "1.5",
42+
}}
43+
>
44+
<AlertTriangle size={18} style={{ flexShrink: 0 }} />
45+
<span>
46+
<strong>{t("networkWarning.wrongNetwork")}</strong>{" "}
47+
{t("networkWarning.walletOn")}{" "}
48+
<strong>{walletNetwork}</strong>,{" "}
49+
{t("networkWarning.appRequires")}{" "}
50+
<strong>{expectedNetwork}</strong>.{" "}
51+
{t("networkWarning.switchInstructions").replace("{{network}}", expectedNetwork ?? "")}
52+
</span>
53+
<button
54+
type="button"
55+
onClick={() => setIsGuideOpen(true)}
56+
style={{
57+
background: "rgba(255, 255, 255, 0.15)",
58+
border: "1px solid rgba(255, 255, 255, 0.4)",
59+
borderRadius: "6px",
60+
color: "#fff",
61+
padding: "4px 10px",
62+
fontSize: "0.8rem",
63+
fontWeight: 600,
64+
cursor: "pointer",
65+
flexShrink: 0,
66+
}}
67+
>
68+
{t("networkWarning.fixNow")}
69+
</button>
70+
</div>
71+
<NetworkMismatchGuideModal
72+
isOpen={isGuideOpen}
73+
onClose={() => setIsGuideOpen(false)}
74+
isMismatch={isMismatch}
75+
isChecking={isChecking}
76+
walletNetwork={walletNetwork}
77+
expectedNetwork={expectedNetwork}
78+
onCheckNow={checkNow}
79+
/>
80+
</>
4981
);
5082
};
5183

0 commit comments

Comments
 (0)