feat: add custom error boundaries with navigation
Replace Next.js default error boundary with custom error pages that preserve navigation and provide better UX. **Problem:** - Default Next.js error boundary shows generic message with NO navigation - Users have no way to recover or navigate away from error - Message: "Application error: a client-side exception has occurred (see the browser console for more information)." **Solution:** - Add custom error.tsx at root level with navigation links - Add custom error.tsx in /arcade with PageWithNav component - Both provide: - Clear error message - "Try Again" button to reset error boundary - Navigation links (home, arcade lobby) - Collapsible technical details for debugging **Benefits:** - Users can always navigate away from errors - Arcade errors keep the nav bar for consistent UX - Root errors provide basic navigation links - Professional error pages instead of generic Next.js message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6a9f3f09ed
commit
73cc4185c3
|
|
@ -0,0 +1,191 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { PageWithNav } from '@/components/PageWithNav'
|
||||||
|
import { css } from '../../../styled-system/css'
|
||||||
|
|
||||||
|
export default function ArcadeError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[Arcade Error Boundary]', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWithNav navTitle="Error" navEmoji="⚠️">
|
||||||
|
<div
|
||||||
|
data-component="arcade-error-page"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '60vh',
|
||||||
|
padding: '32px',
|
||||||
|
textAlign: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Error icon */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '64px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
⚠️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error title */}
|
||||||
|
<h1
|
||||||
|
className={css({
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '16px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Something Went Wrong
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '18px',
|
||||||
|
color: 'gray.600',
|
||||||
|
marginBottom: '32px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
The game encountered an unexpected error. You can try reloading the game, or return to the
|
||||||
|
arcade lobby.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
data-action="retry-game"
|
||||||
|
className={css({
|
||||||
|
padding: '12px 32px',
|
||||||
|
backgroundColor: 'blue.600',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'blue.700',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/arcade-rooms"
|
||||||
|
data-action="return-to-lobby"
|
||||||
|
className={css({
|
||||||
|
padding: '12px 32px',
|
||||||
|
backgroundColor: 'gray.200',
|
||||||
|
color: 'gray.800',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'gray.300',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Return to Lobby
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical details (collapsed by default) */}
|
||||||
|
<details
|
||||||
|
className={css({
|
||||||
|
marginTop: '48px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
className={css({
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'gray.600',
|
||||||
|
_hover: {
|
||||||
|
color: 'gray.800',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Show technical details
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'left',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Error: {error.message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error.digest && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'gray.600',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Digest: {error.digest}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error.stack && (
|
||||||
|
<pre
|
||||||
|
className={css({
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: 'gray.700',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '200px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</PageWithNav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { css } from '../../styled-system/css'
|
||||||
|
|
||||||
|
export default function RootError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[Root Error Boundary]', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
data-component="root-error-page"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '32px',
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: 'gray.50',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Error icon */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '64px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
⚠️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error title */}
|
||||||
|
<h1
|
||||||
|
className={css({
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '16px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Something Went Wrong
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '18px',
|
||||||
|
color: 'gray.600',
|
||||||
|
marginBottom: '32px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
The application encountered an unexpected error. You can try reloading the page, or
|
||||||
|
return to the home page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
data-action="retry-page"
|
||||||
|
className={css({
|
||||||
|
padding: '12px 32px',
|
||||||
|
backgroundColor: 'blue.600',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'blue.700',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
data-action="return-home"
|
||||||
|
className={css({
|
||||||
|
padding: '12px 32px',
|
||||||
|
backgroundColor: 'gray.200',
|
||||||
|
color: 'gray.800',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'gray.300',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Return Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<nav
|
||||||
|
className={css({
|
||||||
|
marginTop: '48px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '24px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className={css({
|
||||||
|
color: 'blue.600',
|
||||||
|
textDecoration: 'none',
|
||||||
|
_hover: { textDecoration: 'underline' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/arcade-rooms"
|
||||||
|
className={css({
|
||||||
|
color: 'blue.600',
|
||||||
|
textDecoration: 'none',
|
||||||
|
_hover: { textDecoration: 'underline' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Arcade
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/calendar"
|
||||||
|
className={css({
|
||||||
|
color: 'blue.600',
|
||||||
|
textDecoration: 'none',
|
||||||
|
_hover: { textDecoration: 'underline' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Calendar
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Technical details (collapsed by default) */}
|
||||||
|
<details
|
||||||
|
className={css({
|
||||||
|
marginTop: '48px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
className={css({
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'gray.600',
|
||||||
|
_hover: {
|
||||||
|
color: 'gray.800',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Show technical details
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'left',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'gray.200',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Error: {error.message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error.digest && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'gray.600',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Digest: {error.digest}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error.stack && (
|
||||||
|
<pre
|
||||||
|
className={css({
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: 'gray.700',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '200px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue