Skip to content

Commit f0bba41

Browse files
authored
Merge pull request #303 from magaret457/feature/offline-detection-ux
feat(offline): add useNetworkStatus hook, NetworkBanner, and React Query sync (#241)
2 parents 38bb431 + fc6af1a commit f0bba41

6 files changed

Lines changed: 218 additions & 0 deletions

File tree

frontend/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Toaster } from "@/components/ui/toaster";
99
import { WalletProvider, NetworkMismatchModal } from "@/features/wallet";
1010
import { inter, ibmPlexMono } from "@/lib/fonts";
1111
import { QueryProvider } from "@/lib/query";
12+
import { NetworkBanner } from "@/components/ui/network-banner";
1213

1314
export const viewport: Viewport = {
1415
width: "device-width",
@@ -86,6 +87,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
8687
<ThemeProvider defaultTheme="system" storageKey="niffyinsur-theme">
8788
<QueryProvider>
8889
<WalletProvider>
90+
<NetworkBanner />
8991
{children}
9092
<CookieConsentBanner />
9193
<NetworkMismatchModal />
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Tests for useNetworkStatus hook and NetworkBanner component.
3+
* Uses Jest + @testing-library/react (jsdom environment).
4+
*/
5+
import { act, renderHook } from '@testing-library/react'
6+
import { render, screen } from '@testing-library/react'
7+
import { onlineManager } from '@tanstack/react-query'
8+
9+
// ── Helpers ──────────────────────────────────────────────────────────────────
10+
11+
function fireOnline() {
12+
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
13+
window.dispatchEvent(new Event('online'))
14+
}
15+
16+
function fireOffline() {
17+
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
18+
window.dispatchEvent(new Event('offline'))
19+
}
20+
21+
// ── useNetworkStatus ──────────────────────────────────────────────────────────
22+
23+
describe('useNetworkStatus', () => {
24+
beforeEach(() => {
25+
// Start each test online
26+
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
27+
})
28+
29+
it('returns isOnline=true when navigator.onLine is true', async () => {
30+
const { useNetworkStatus } = await import('@/hooks/use-network-status')
31+
const { result } = renderHook(() => useNetworkStatus())
32+
expect(result.current.isOnline).toBe(true)
33+
})
34+
35+
it('transitions to isOnline=false on offline event', async () => {
36+
const { useNetworkStatus } = await import('@/hooks/use-network-status')
37+
const { result } = renderHook(() => useNetworkStatus())
38+
39+
act(() => { fireOffline() })
40+
41+
expect(result.current.isOnline).toBe(false)
42+
})
43+
44+
it('transitions back to isOnline=true on online event', async () => {
45+
const { useNetworkStatus } = await import('@/hooks/use-network-status')
46+
const { result } = renderHook(() => useNetworkStatus())
47+
48+
act(() => { fireOffline() })
49+
expect(result.current.isOnline).toBe(false)
50+
51+
act(() => { fireOnline() })
52+
expect(result.current.isOnline).toBe(true)
53+
})
54+
55+
it('calls onlineManager.setOnline(false) when going offline', async () => {
56+
const spy = jest.spyOn(onlineManager, 'setOnline')
57+
const { useNetworkStatus } = await import('@/hooks/use-network-status')
58+
renderHook(() => useNetworkStatus())
59+
60+
act(() => { fireOffline() })
61+
62+
expect(spy).toHaveBeenCalledWith(false)
63+
spy.mockRestore()
64+
})
65+
66+
it('calls onlineManager.setOnline(true) when coming back online', async () => {
67+
const spy = jest.spyOn(onlineManager, 'setOnline')
68+
const { useNetworkStatus } = await import('@/hooks/use-network-status')
69+
renderHook(() => useNetworkStatus())
70+
71+
act(() => { fireOffline() })
72+
act(() => { fireOnline() })
73+
74+
expect(spy).toHaveBeenCalledWith(true)
75+
spy.mockRestore()
76+
})
77+
})
78+
79+
// ── NetworkBanner ─────────────────────────────────────────────────────────────
80+
81+
describe('NetworkBanner', () => {
82+
beforeEach(() => {
83+
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
84+
})
85+
86+
it('renders nothing when online', async () => {
87+
const { NetworkBanner } = await import('@/components/ui/network-banner')
88+
const { container } = render(<NetworkBanner />)
89+
expect(container.firstChild).toBeNull()
90+
})
91+
92+
it('renders the banner when offline', async () => {
93+
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
94+
const { NetworkBanner } = await import('@/components/ui/network-banner')
95+
render(<NetworkBanner />)
96+
expect(screen.getByTestId('network-banner')).toBeInTheDocument()
97+
expect(screen.getByRole('alert')).toBeInTheDocument()
98+
})
99+
100+
it('hides the banner after coming back online', async () => {
101+
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
102+
const { NetworkBanner } = await import('@/components/ui/network-banner')
103+
render(<NetworkBanner />)
104+
expect(screen.getByTestId('network-banner')).toBeInTheDocument()
105+
106+
act(() => { fireOnline() })
107+
108+
expect(screen.queryByTestId('network-banner')).toBeNull()
109+
})
110+
})

frontend/src/components/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export { Badge, badgeVariants } from './badge'
5050
export { WalletAddress, type WalletAddressProps, type StellarNetwork } from './wallet-address'
5151
export { Stepper, StepContent, type Step } from './stepper'
5252
export { InlineError } from './inline-error'
53+
export { NetworkBanner } from './network-banner'
54+
export { WriteRetryButton } from './write-retry-button'
5355
export { VoteEducationPanel } from '../claims/vote-education-panel'
5456
export { VoteTally } from '../claims/vote-tally'
5557
export { VoteConfirmModal } from '../claims/vote-confirm-modal'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client'
2+
3+
import { WifiOff } from 'lucide-react'
4+
import { useNetworkStatus } from '@/hooks/use-network-status'
5+
6+
/**
7+
* NetworkBanner — non-blocking, persistent banner shown while the browser is
8+
* offline. Sits at the top of the viewport (z-[200]) so it never obscures
9+
* interactive content. Disappears automatically on reconnection.
10+
*/
11+
export function NetworkBanner() {
12+
const { isOnline } = useNetworkStatus()
13+
14+
if (isOnline) return null
15+
16+
return (
17+
<div
18+
role="alert"
19+
aria-live="assertive"
20+
data-testid="network-banner"
21+
className="fixed inset-x-0 top-0 z-[200] flex items-center gap-2 bg-yellow-500 px-4 py-2 text-sm font-medium text-yellow-950"
22+
style={{ paddingTop: `calc(0.5rem + env(safe-area-inset-top, 0px))` }}
23+
>
24+
<WifiOff className="h-4 w-4 shrink-0" aria-hidden="true" />
25+
<span>You&apos;re offline. Some features may be unavailable.</span>
26+
</div>
27+
)
28+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client'
2+
3+
import { Button } from '@/components/ui/button'
4+
import { useNetworkStatus } from '@/hooks/use-network-status'
5+
6+
interface WriteRetryButtonProps {
7+
onRetry: () => void
8+
label?: string
9+
disabled?: boolean
10+
}
11+
12+
/**
13+
* Shown after a write operation (transaction) fails while offline.
14+
* Write ops must NOT auto-retry — the user must explicitly confirm.
15+
*/
16+
export function WriteRetryButton({
17+
onRetry,
18+
label = 'Retry',
19+
disabled,
20+
}: WriteRetryButtonProps) {
21+
const { isOnline } = useNetworkStatus()
22+
23+
return (
24+
<Button
25+
variant="outline"
26+
size="sm"
27+
onClick={onRetry}
28+
disabled={disabled || !isOnline}
29+
title={!isOnline ? 'Reconnect to retry' : undefined}
30+
>
31+
{label}
32+
</Button>
33+
)
34+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { onlineManager } from '@tanstack/react-query'
5+
6+
export interface NetworkStatus {
7+
isOnline: boolean
8+
}
9+
10+
/**
11+
* Tracks browser online/offline state and syncs React Query's onlineManager
12+
* so background refetching is automatically paused while offline and resumed
13+
* on reconnection.
14+
*/
15+
export function useNetworkStatus(): NetworkStatus {
16+
const [isOnline, setIsOnline] = useState(
17+
typeof navigator !== 'undefined' ? navigator.onLine : true,
18+
)
19+
20+
useEffect(() => {
21+
function handleOnline() {
22+
setIsOnline(true)
23+
onlineManager.setOnline(true)
24+
}
25+
function handleOffline() {
26+
setIsOnline(false)
27+
onlineManager.setOnline(false)
28+
}
29+
30+
// Sync initial state with React Query
31+
onlineManager.setOnline(navigator.onLine)
32+
33+
window.addEventListener('online', handleOnline)
34+
window.addEventListener('offline', handleOffline)
35+
return () => {
36+
window.removeEventListener('online', handleOnline)
37+
window.removeEventListener('offline', handleOffline)
38+
}
39+
}, [])
40+
41+
return { isOnline }
42+
}

0 commit comments

Comments
 (0)