Skip to content

Commit 2d2d2af

Browse files
authored
Merge pull request #398 from adetomiwa21/fix/offline-banner-auth-redirect-error-boundary
2 parents abf82f8 + 647c1ed commit 2d2d2af

11 files changed

Lines changed: 434 additions & 18 deletions

File tree

app/(merchant)/layout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
55
import { AlertTriangle } from "lucide-react";
66
import { MerchantSidebar } from "@/components/layout";
77
import { merchantNavItems } from "@/lib/navigation/merchantNav";
8-
import { PageTransition } from "@/components/shared";
8+
import { PageTransition, ErrorBoundary } from "@/components/shared";
99
import { MobileNavDrawer } from "@/components/layout";
1010
import { Topbar } from "@/components/layout";
1111
import Footer from "@/components/layout";
@@ -84,7 +84,9 @@ export default function MerchantLayout({
8484
<main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto bg-background/50 pb-20 md:pb-0">
8585
<div className="mx-auto max-w-7xl px-3 sm:px-6 py-4 sm:py-8 space-y-6">
8686
<OnboardingWizard />
87-
<PageTransition>{children}</PageTransition>
87+
<PageTransition>
88+
<ErrorBoundary>{children}</ErrorBoundary>
89+
</PageTransition>
8890
</div>
8991
</main>
9092

components/providers.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,25 @@
22

33
import { ThemeProvider } from "next-themes";
44
import { ReactNode, useEffect, useRef, useState } from "react";
5+
import { useRouter } from "next/navigation";
56
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
67
import { useAuthStore } from "@/lib/store/authStore";
78
import { useSessionCheck } from "@/lib/hooks/useSessionCheck";
89
import { useCrossTabAuth } from "@/lib/hooks/useCrossTabAuth";
10+
import { setAppRouter } from "@/lib/navigation/appRouter";
911
import { OfflineBanner } from "@/components/ui";
1012

1113
export function Providers({ children }: { children: ReactNode }) {
1214
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
15+
const router = useRouter();
16+
17+
// Register the App Router in a module-level singleton so non-React code
18+
// (e.g. the axios auth interceptor) can navigate with router.push instead of
19+
// a full-page reload. Clear it on unmount to avoid holding a stale router.
20+
useEffect(() => {
21+
setAppRouter(router);
22+
return () => setAppRouter(null);
23+
}, [router]);
1324

1425
// SSR-safe lazy initialisation keeps a stable QueryClient per browser
1526
// session while making sure each server render starts with its own
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use client";
2+
3+
import { Component, type ReactNode } from "react";
4+
import Link from "next/link";
5+
import { AlertTriangle, RotateCcw } from "lucide-react";
6+
7+
const buttonBase =
8+
"inline-flex items-center justify-center rounded-lg px-4 h-11 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
9+
10+
interface ErrorBoundaryProps {
11+
children: ReactNode;
12+
/** Optional custom fallback; when omitted the default error card is shown. */
13+
fallback?: ReactNode;
14+
}
15+
16+
interface ErrorBoundaryState {
17+
hasError: boolean;
18+
}
19+
20+
/**
21+
* Catches render errors thrown by descendant page components (e.g. a chart that
22+
* blows up) and shows a recoverable fallback card instead of a white screen.
23+
*/
24+
export class ErrorBoundary extends Component<
25+
ErrorBoundaryProps,
26+
ErrorBoundaryState
27+
> {
28+
state: ErrorBoundaryState = { hasError: false };
29+
30+
static getDerivedStateFromError(): ErrorBoundaryState {
31+
return { hasError: true };
32+
}
33+
34+
componentDidCatch(error: unknown, info: unknown) {
35+
// Log so the failure is still visible in the console / error reporting.
36+
console.error("Unhandled render error caught by ErrorBoundary", error, info);
37+
}
38+
39+
handleReset = () => {
40+
// Reset the boundary so the children get a fresh render attempt.
41+
this.setState({ hasError: false });
42+
};
43+
44+
render() {
45+
if (!this.state.hasError) return this.props.children;
46+
if (this.props.fallback) return this.props.fallback;
47+
48+
return (
49+
<div className="flex min-h-[60vh] items-center justify-center p-4">
50+
<div
51+
role="alert"
52+
className="w-full max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm"
53+
>
54+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
55+
<AlertTriangle
56+
className="h-6 w-6 text-destructive"
57+
aria-hidden="true"
58+
/>
59+
</div>
60+
<h2 className="text-lg font-semibold text-foreground">
61+
Something went wrong
62+
</h2>
63+
<p className="mt-2 text-sm text-muted-foreground">
64+
This page ran into an unexpected error. You can try again, or head
65+
back to your dashboard.
66+
</p>
67+
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-center">
68+
<button
69+
type="button"
70+
onClick={this.handleReset}
71+
className={`${buttonBase} gap-2 bg-primary text-primary-foreground hover:bg-primary/80`}
72+
>
73+
<RotateCcw className="h-4 w-4" aria-hidden="true" />
74+
Try Again
75+
</button>
76+
<Link
77+
href="/dashboard"
78+
className={`${buttonBase} border border-border bg-background hover:bg-muted hover:text-foreground`}
79+
>
80+
Go to Dashboard
81+
</Link>
82+
</div>
83+
</div>
84+
</div>
85+
);
86+
}
87+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
3+
4+
// next/link needs no router context in these unit tests; render a plain anchor.
5+
jest.mock('next/link', () => {
6+
const React = require('react');
7+
return {
8+
__esModule: true,
9+
default: ({ href, children, ...props }: any) =>
10+
React.createElement('a', { href, ...props }, children),
11+
};
12+
});
13+
14+
// Child whose throwing is toggleable so we can exercise the reset path.
15+
let shouldThrow = true;
16+
function MaybeBoom() {
17+
if (shouldThrow) throw new Error('render blew up');
18+
return <div>recovered content</div>;
19+
}
20+
21+
describe('ErrorBoundary', () => {
22+
let consoleErrorSpy: jest.SpyInstance;
23+
24+
beforeEach(() => {
25+
shouldThrow = true;
26+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
27+
});
28+
29+
afterEach(() => {
30+
consoleErrorSpy.mockRestore();
31+
});
32+
33+
it('renders children when nothing throws', () => {
34+
shouldThrow = false;
35+
render(
36+
<ErrorBoundary>
37+
<MaybeBoom />
38+
</ErrorBoundary>
39+
);
40+
expect(screen.getByText('recovered content')).toBeInTheDocument();
41+
});
42+
43+
it('renders the fallback UI and logs the error when a child throws', () => {
44+
render(
45+
<ErrorBoundary>
46+
<MaybeBoom />
47+
</ErrorBoundary>
48+
);
49+
50+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
51+
expect(
52+
screen.getByRole('button', { name: /try again/i })
53+
).toBeInTheDocument();
54+
expect(
55+
screen.getByRole('link', { name: /go to dashboard/i })
56+
).toHaveAttribute('href', '/dashboard');
57+
58+
expect(consoleErrorSpy).toHaveBeenCalledWith(
59+
'Unhandled render error caught by ErrorBoundary',
60+
expect.anything(),
61+
expect.anything()
62+
);
63+
});
64+
65+
it('resets and re-renders children when Try Again is clicked', () => {
66+
render(
67+
<ErrorBoundary>
68+
<MaybeBoom />
69+
</ErrorBoundary>
70+
);
71+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
72+
73+
// The child stops throwing, then the user retries.
74+
shouldThrow = false;
75+
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
76+
77+
expect(screen.getByText('recovered content')).toBeInTheDocument();
78+
expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
79+
});
80+
});

components/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './PageTransition';
2+
export * from './ErrorBoundary';
23
export * from './StatCard';
34
export * from './CurrencyDisplay';
45
export * from './ErrorDisplay';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { render, screen, fireEvent, act } from '@testing-library/react';
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3+
import { OfflineBanner } from '@/components/ui/offline-banner';
4+
import { useOfflineStore } from '@/lib/store/offlineStore';
5+
6+
// Control the browser-detected connectivity that feeds the store.
7+
let mockOnline = false;
8+
jest.mock('@/lib/hooks/useOnlineStatus', () => ({
9+
useOnlineStatus: () => mockOnline,
10+
}));
11+
12+
function renderBanner() {
13+
const client = new QueryClient({
14+
defaultOptions: { queries: { retry: false } },
15+
});
16+
jest.spyOn(client, 'refetchQueries').mockResolvedValue(undefined);
17+
const utils = render(
18+
<QueryClientProvider client={client}>
19+
<OfflineBanner />
20+
</QueryClientProvider>
21+
);
22+
return { client, ...utils };
23+
}
24+
25+
const bannerText = () => screen.queryByText(/you are offline/i);
26+
27+
beforeEach(() => {
28+
mockOnline = false;
29+
act(() => {
30+
useOfflineStore.setState({ isOnline: true, dismissed: false });
31+
});
32+
});
33+
34+
describe('OfflineBanner', () => {
35+
it('reflects useOfflineStore connectivity state', () => {
36+
renderBanner();
37+
// Store transitioned to offline on mount -> banner visible.
38+
expect(bannerText()).toBeInTheDocument();
39+
40+
act(() => {
41+
useOfflineStore.getState().setIsOnline(true);
42+
});
43+
// Back online per the store -> banner hidden.
44+
expect(bannerText()).not.toBeInTheDocument();
45+
});
46+
47+
it('is dismissible and reappears on the next offline transition', () => {
48+
renderBanner();
49+
expect(bannerText()).toBeInTheDocument();
50+
51+
fireEvent.click(
52+
screen.getByRole('button', { name: /dismiss offline notification/i })
53+
);
54+
expect(bannerText()).not.toBeInTheDocument();
55+
56+
// Reconnect, then drop offline again: dismissal must reset so the banner
57+
// comes back instead of staying hidden forever.
58+
act(() => {
59+
useOfflineStore.getState().setIsOnline(true);
60+
});
61+
act(() => {
62+
useOfflineStore.getState().setIsOnline(false);
63+
});
64+
expect(bannerText()).toBeInTheDocument();
65+
});
66+
67+
it('re-fetches failed data when Retry is clicked', () => {
68+
const { client } = renderBanner();
69+
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
70+
expect(client.refetchQueries).toHaveBeenCalled();
71+
});
72+
});

components/ui/offline-banner.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,62 @@
11
'use client';
22

33
import { useEffect } from 'react';
4-
import { WifiOff } from 'lucide-react';
4+
import { WifiOff, RotateCw, X } from 'lucide-react';
5+
import { useQueryClient } from '@tanstack/react-query';
56
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus';
67
import { useOfflineStore } from '@/lib/store/offlineStore';
78

89
export function OfflineBanner() {
9-
const isOnline = useOnlineStatus();
10+
const detectedOnline = useOnlineStatus();
11+
const isOnline = useOfflineStore((s) => s.isOnline);
12+
const dismissed = useOfflineStore((s) => s.dismissed);
1013
const setIsOnline = useOfflineStore((s) => s.setIsOnline);
14+
const dismiss = useOfflineStore((s) => s.dismiss);
15+
const queryClient = useQueryClient();
1116

17+
// Feed the browser-detected connectivity into the shared store so the banner
18+
// (and the rest of the app) render from a single source of truth.
1219
useEffect(() => {
13-
setIsOnline(isOnline);
14-
}, [isOnline, setIsOnline]);
20+
setIsOnline(detectedOnline);
21+
}, [detectedOnline, setIsOnline]);
1522

16-
if (isOnline) return null;
23+
// Only show when the store says we are truly offline and the user hasn't
24+
// dismissed it for this offline episode.
25+
if (isOnline || dismissed) return null;
26+
27+
const handleRetry = () => {
28+
// Re-fetch any data that failed while offline. Queries that are still
29+
// failing (because we're still offline) simply error again and leave the
30+
// banner in place.
31+
queryClient.refetchQueries();
32+
};
1733

1834
return (
1935
<div
2036
role="alert"
2137
aria-live="polite"
22-
className="fixed top-0 left-0 right-0 z-[60] bg-destructive text-destructive-foreground px-4 py-2.5 flex items-center justify-center gap-2 shadow-md animate-in slide-in-from-top duration-300"
38+
className="fixed top-0 left-0 right-0 z-[60] bg-destructive text-destructive-foreground px-4 py-2.5 flex items-center justify-center gap-3 shadow-md animate-in slide-in-from-top duration-300"
2339
>
2440
<WifiOff className="w-4 h-4 shrink-0" aria-hidden="true" />
2541
<span className="text-sm font-medium">
2642
You are offline. Some features may be unavailable.
2743
</span>
44+
<button
45+
type="button"
46+
onClick={handleRetry}
47+
className="inline-flex items-center gap-1 rounded-md border border-destructive-foreground/40 px-2 py-1 text-xs font-medium hover:bg-destructive-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive-foreground/60"
48+
>
49+
<RotateCw className="w-3.5 h-3.5" aria-hidden="true" />
50+
Retry
51+
</button>
52+
<button
53+
type="button"
54+
onClick={dismiss}
55+
aria-label="Dismiss offline notification"
56+
className="ml-1 inline-flex items-center justify-center rounded-md p-1 hover:bg-destructive-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive-foreground/60"
57+
>
58+
<X className="w-4 h-4" aria-hidden="true" />
59+
</button>
2860
</div>
2961
);
3062
}

0 commit comments

Comments
 (0)