Skip to content
Open
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
46 changes: 46 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,50 @@ describe("App routing", () => {

expect(screen.getByTestId("active-section")).toHaveTextContent("network");
});

it("displays a persistent offline notification banner on offline event and clears on online (#966)", () => {
vi.mocked(useSorokit).mockReturnValue({
isConnected: true,
} as ReturnType<typeof useSorokit>);
render(<App client={createMockClient() as SorokitClient} />);

expect(screen.queryByTestId("offline-banner")).not.toBeInTheDocument();

fireEvent(window, new Event("offline"));
expect(screen.getByTestId("offline-banner")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(/offline/i);

fireEvent(window, new Event("online"));
expect(screen.queryByTestId("offline-banner")).not.toBeInTheDocument();
});

it("renders a focusable Skip to main content landmark link (#966)", () => {
vi.mocked(useSorokit).mockReturnValue({
isConnected: true,
} as ReturnType<typeof useSorokit>);
render(<App client={createMockClient() as SorokitClient} />);

const skipLink = screen.getByRole("link", { name: /skip to main content/i });
expect(skipLink).toHaveAttribute("href", "#main-content");
skipLink.focus();
expect(document.activeElement).toBe(skipLink);
});

it("renders a 404 Not Found card for unknown route URLs with a Back to Dashboard button (#966)", () => {
setPath("/unknown-route-xyz");
vi.mocked(useSorokit).mockReturnValue({
isConnected: true,
} as ReturnType<typeof useSorokit>);
render(<App client={createMockClient() as SorokitClient} />);

expect(screen.getByTestId("not-found-card")).toBeInTheDocument();
expect(screen.getByText(/404/i)).toBeInTheDocument();

const backBtn = screen.getByRole("button", { name: /back to dashboard/i });
fireEvent.click(backBtn);

expect(window.location.pathname).toBe("/");
expect(screen.getByTestId("dashboard")).toBeInTheDocument();
});
});

105 changes: 87 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,120 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from "react";

import type { NavSection } from './components/Sidebar';
import { SorokitProvider } from './context/SorokitProvider';
import { useSorokit } from './context/useSorokit';
import type { SorokitClient } from './lib/client';
import { SECTION_PATHS,sectionForPath } from './lib/nav-routes';
import { ConnectScreen } from './screens/ConnectScreen';
import { Dashboard } from './screens/Dashboard';
import type { NavSection } from "./components/Sidebar";
import { SorokitProvider } from "./context/SorokitProvider";
import { useSorokit } from "./context/useSorokit";
import type { SorokitClient } from "./lib/client";
import { isKnownPath, SECTION_PATHS, sectionForPath } from "./lib/nav-routes";
import { ConnectScreen } from "./screens/ConnectScreen";
import { Dashboard } from "./screens/Dashboard";

interface AppProps {
client: SorokitClient;
}

function AppContent() {
const { isConnected } = useSorokit();
const [pathname, setPathname] = useState<string>(() => window.location.pathname);
const [section, setSection] = useState<NavSection>(() =>
sectionForPath(window.location.pathname),
);
const [isOffline, setIsOffline] = useState<boolean>(() =>
typeof navigator !== "undefined" ? navigator.onLine === false : false,
);

// Listen for browser online / offline connectivity events (#966)
useEffect(() => {
const handleOffline = () => setIsOffline(true);
const handleOnline = () => setIsOffline(false);
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
};
}, []);

// Browser back/forward.
useEffect(() => {
const onPopState = () => setSection(sectionForPath(window.location.pathname));
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
const onPopState = () => {
setPathname(window.location.pathname);
setSection(sectionForPath(window.location.pathname));
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);

const handleSectionChange = useCallback((next: NavSection) => {
setSection(next);
const path = SECTION_PATHS[next];
if (window.location.pathname !== path) {
window.history.pushState(null, '', path);
window.history.pushState(null, "", path);
}
setPathname(path);
}, []);

const handleBackToDashboard = useCallback(() => {
window.history.pushState(null, "", "/");
setPathname("/");
setSection("wallet");
}, []);

// Dashboard section URLs are only meaningful once connected; bounce back
// to "/" so a shared /soroban (etc.) link doesn't strand a signed-out user.
useEffect(() => {
if (!isConnected && window.location.pathname !== '/') {
window.history.replaceState(null, '', '/');
if (!isConnected && window.location.pathname !== "/") {
window.history.replaceState(null, "", "/");
setPathname("/");
}
}, [isConnected]);

if (!isConnected) {
return <ConnectScreen />;
}
const unknownRoute = isConnected && !isKnownPath(pathname);

return <Dashboard activeSection={section} onSectionChange={handleSectionChange} />;
return (
<>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:rounded-lg focus:bg-surface focus:text-ink focus:border focus:border-line"
>
Skip to main content
</a>
{isOffline && (
<div
role="alert"
data-testid="offline-banner"
className="w-full bg-error-dim text-red px-4 py-2 text-center text-[13px] font-medium border-b border-line"
>
You are currently offline. Transactions cannot be submitted until your internet connection is restored.
</div>
)}
<div id="main-content" tabIndex={-1}>
{!isConnected ? (
<ConnectScreen />
) : unknownRoute ? (
<div
role="region"
aria-label="Page not found"
data-testid="not-found-card"
className="flex flex-col items-center justify-center p-8 gap-4"
>
<h1 className="text-[20px] font-semibold text-ink">404 - Page Not Found</h1>
<p className="text-[13px] text-ink-3">
The page at <code>{pathname}</code> does not exist.
</p>
<button
type="button"
onClick={handleBackToDashboard}
className="px-4 py-2 rounded-lg bg-brand text-white text-[13px] font-medium"
>
Back to Dashboard
</button>
</div>
) : (
<Dashboard activeSection={section} onSectionChange={handleSectionChange} />
)}
</div>
</>
);
}

function App({ client }: AppProps) {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/nav-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const PATH_TO_SECTION: Record<string, NavSection> = Object.fromEntries(
Object.entries(SECTION_PATHS).map(([section, path]) => [path, section]),
) as Record<string, NavSection>;

export function isKnownPath(pathname: string): boolean {
return Object.prototype.hasOwnProperty.call(PATH_TO_SECTION, pathname);
}

export function sectionForPath(pathname: string): NavSection {
return PATH_TO_SECTION[pathname] ?? "wallet";
}