feat: add comprehensive error handling for arcade games
Add user-facing error notifications and boundaries to prevent silent failures in arcade games. **Problem:** - Errors only logged to console (e.g., "Failed to fetch session") - Users saw nothing when errors occurred - Buttons stopped working with no feedback - No way to recover from errors **Solution: 4-Part Error Handling System** 1. **ErrorToast Component** - User-facing error notifications - Prominent red toast in bottom-right corner - Auto-dismisses after 10 seconds - Collapsible technical details - Mobile-responsive 2. **ArcadeErrorBoundary** - React error boundary - Catches component render errors - Shows user-friendly fallback UI - Provides "Try Again" and "Return to Lobby" buttons - Collapsible stack trace for debugging 3. **ArcadeErrorContext** - Global error management - Manages error state across the app - Renders error toasts - Auto-cleans up old errors 4. **Enhanced useArcadeSocket** - Automatic socket error handling - Connection errors: "Failed to connect to server" - Disconnections: "Connection lost, attempting to reconnect" - Session errors: "Failed to load/update session" - Move rejections: "Your move was not accepted" - No active session: "No game session found" - Can suppress toasts with `suppressErrorToasts: true` **Files Created:** - src/components/ErrorToast.tsx - src/components/ArcadeErrorBoundary.tsx - src/contexts/ArcadeErrorContext.tsx - .claude/ERROR_HANDLING.md (integration guide) **Files Modified:** - src/hooks/useArcadeSocket.ts (automatic error toasts) **Next Steps (TODO):** - Wrap arcade game pages with error providers - Test all error scenarios - Add error recovery strategies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
07c25a2296
commit
e8c52561a2
|
|
@ -0,0 +1,188 @@
|
||||||
|
# Arcade Error Handling System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Comprehensive error handling system for arcade games to ensure users always see meaningful error messages instead of silent failures.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. ErrorToast (`src/components/ErrorToast.tsx`)
|
||||||
|
|
||||||
|
User-facing error notification component:
|
||||||
|
- Prominent red toast in bottom-right corner
|
||||||
|
- Auto-dismisses after 10 seconds
|
||||||
|
- Collapsible technical details
|
||||||
|
- Mobile-responsive
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
<ErrorToast
|
||||||
|
message="Game session error"
|
||||||
|
details="Error: Failed to fetch session..."
|
||||||
|
onDismiss={() => clearError(errorId)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ArcadeErrorBoundary (`src/components/ArcadeErrorBoundary.tsx`)
|
||||||
|
|
||||||
|
React error boundary for catching React errors:
|
||||||
|
- Catches component render errors
|
||||||
|
- Shows user-friendly fallback UI
|
||||||
|
- Provides "Try Again" and "Return to Lobby" buttons
|
||||||
|
- Collapsible stack trace for debugging
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
<ArcadeErrorBoundary>
|
||||||
|
<GameComponent />
|
||||||
|
</ArcadeErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ArcadeErrorContext (`src/contexts/ArcadeErrorContext.tsx`)
|
||||||
|
|
||||||
|
Global error management context:
|
||||||
|
- Manages error state across the app
|
||||||
|
- Renders error toasts
|
||||||
|
- Auto-cleans up old errors
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
// Wrap your app/page
|
||||||
|
<ArcadeErrorProvider>
|
||||||
|
{children}
|
||||||
|
</ArcadeErrorProvider>
|
||||||
|
|
||||||
|
// Use in components
|
||||||
|
const { addError, clearError } = useArcadeError()
|
||||||
|
addError('Something went wrong', 'Technical details...')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Enhanced useArcadeSocket Hook
|
||||||
|
|
||||||
|
Socket hook now automatically shows error toasts for:
|
||||||
|
- **Connection errors**: Failed to connect to server
|
||||||
|
- **Disconnections**: Connection lost
|
||||||
|
- **Session errors**: Failed to load/update session
|
||||||
|
- **Move rejections**: Invalid moves (non-version-conflict)
|
||||||
|
- **No active session**: Session not found
|
||||||
|
|
||||||
|
Can suppress toasts with `suppressErrorToasts: true` option.
|
||||||
|
|
||||||
|
## Error Categories
|
||||||
|
|
||||||
|
### Network/Connection Errors
|
||||||
|
- **Connection error**: Failed to connect to game server
|
||||||
|
- **Disconnection**: Connection lost, attempting to reconnect
|
||||||
|
|
||||||
|
### Session Errors
|
||||||
|
- **Session error**: Failed to load or update game session
|
||||||
|
- **No active session**: No game session found
|
||||||
|
|
||||||
|
### Game State Errors
|
||||||
|
- **Move rejected**: Invalid move submitted
|
||||||
|
- **Version conflict**: Concurrent update detected (silent, not shown to user)
|
||||||
|
|
||||||
|
### React Errors
|
||||||
|
- **Component errors**: Caught by ErrorBoundary, shows fallback UI
|
||||||
|
|
||||||
|
## Integration Guide
|
||||||
|
|
||||||
|
### For New Arcade Games
|
||||||
|
|
||||||
|
1. **Wrap your game page with error providers:**
|
||||||
|
```typescript
|
||||||
|
// src/app/arcade/your-game/page.tsx
|
||||||
|
import { ArcadeErrorProvider } from '@/contexts/ArcadeErrorContext'
|
||||||
|
import { ArcadeErrorBoundary } from '@/components/ArcadeErrorBoundary'
|
||||||
|
|
||||||
|
export default function YourGamePage() {
|
||||||
|
return (
|
||||||
|
<ArcadeErrorProvider>
|
||||||
|
<ArcadeErrorBoundary>
|
||||||
|
<YourGameProvider>
|
||||||
|
<YourGameComponent />
|
||||||
|
</YourGameProvider>
|
||||||
|
</ArcadeErrorBoundary>
|
||||||
|
</ArcadeErrorProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use error context in your components:**
|
||||||
|
```typescript
|
||||||
|
import { useArcadeError } from '@/contexts/ArcadeErrorContext'
|
||||||
|
|
||||||
|
function YourComponent() {
|
||||||
|
const { addError } = useArcadeError()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Your code
|
||||||
|
} catch (error) {
|
||||||
|
addError(
|
||||||
|
'User-friendly message',
|
||||||
|
`Technical details: ${error.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Socket hook is automatic:**
|
||||||
|
The `useArcadeSocket` hook already shows errors by default. No changes needed unless you want to suppress them.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO:
|
||||||
|
- ✅ Use `addError()` for runtime errors
|
||||||
|
- ✅ Provide user-friendly primary messages
|
||||||
|
- ✅ Include technical details in the `details` parameter
|
||||||
|
- ✅ Wrap arcade pages with both ErrorProvider and ErrorBoundary
|
||||||
|
- ✅ Let socket errors show automatically (they're handled)
|
||||||
|
|
||||||
|
### DON'T:
|
||||||
|
- ❌ Don't just log errors to console
|
||||||
|
- ❌ Don't show raw error messages to users
|
||||||
|
- ❌ Don't swallow errors silently
|
||||||
|
- ❌ Don't use `alert()` for errors
|
||||||
|
- ❌ Don't forget to wrap new arcade games
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Wrap all arcade game pages with error providers
|
||||||
|
- [ ] Add error recovery strategies (retry buttons)
|
||||||
|
- [ ] Add error reporting/telemetry
|
||||||
|
- [ ] Test all error scenarios
|
||||||
|
- [ ] Document error codes/types
|
||||||
|
|
||||||
|
## Testing Errors
|
||||||
|
|
||||||
|
To test error handling:
|
||||||
|
|
||||||
|
1. **Connection errors**: Disconnect network, try to join game
|
||||||
|
2. **Session errors**: Use invalid room ID
|
||||||
|
3. **Move rejection**: Submit invalid move
|
||||||
|
4. **React errors**: Throw error in component render
|
||||||
|
|
||||||
|
## Example: Know Your World
|
||||||
|
|
||||||
|
The know-your-world game had a "Failed to fetch session" error that was only logged to console. With the new system:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- Error logged to console
|
||||||
|
- User sees nothing, buttons don't work
|
||||||
|
- No way to know what's wrong
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Error toast appears: "Game session error"
|
||||||
|
- Technical details available (collapsible)
|
||||||
|
- User can refresh or return to lobby
|
||||||
|
- Clear actionable feedback
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Existing arcade games need to be updated to wrap with error providers. Priority order:
|
||||||
|
|
||||||
|
1. ✅ useArcadeSocket hook (done)
|
||||||
|
2. ✅ Error components created (done)
|
||||||
|
3. ⏳ Wrap arcade game pages
|
||||||
|
4. ⏳ Test error scenarios
|
||||||
|
5. ⏳ Add recovery strategies
|
||||||
|
|
@ -58,7 +58,8 @@
|
||||||
"Bash(gh run view:*)",
|
"Bash(gh run view:*)",
|
||||||
"Bash(pnpm install:*)",
|
"Bash(pnpm install:*)",
|
||||||
"Bash(git checkout:*)",
|
"Bash(git checkout:*)",
|
||||||
"Bash(node server.js:*)"
|
"Bash(node server.js:*)",
|
||||||
|
"Bash(git fetch:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { Component, type ReactNode } from 'react'
|
||||||
|
import { css } from '../../styled-system/css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: (error: Error, resetError: () => void) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary for arcade games
|
||||||
|
* Catches React errors and displays a user-friendly error UI
|
||||||
|
*/
|
||||||
|
export class ArcadeErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('[ArcadeErrorBoundary] Caught error:', error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetError = () => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError && this.state.error) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback(this.state.error, this.resetError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DefaultErrorFallback error={this.state.error} resetError={this.resetError} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default error fallback UI
|
||||||
|
*/
|
||||||
|
function DefaultErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) {
|
||||||
|
const [showDetails, setShowDetails] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-component="arcade-error-boundary"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '400px',
|
||||||
|
padding: '32px',
|
||||||
|
backgroundColor: 'red.50',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'red.300',
|
||||||
|
margin: '16px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Error icon */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '64px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
⚠️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error title */}
|
||||||
|
<h2
|
||||||
|
className={css({
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'red.900',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Something Went Wrong
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '16px',
|
||||||
|
color: 'red.800',
|
||||||
|
marginBottom: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '600px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
The game encountered an unexpected error. Please try refreshing the page or returning to the arcade
|
||||||
|
lobby.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={resetError}
|
||||||
|
data-action="reset-error"
|
||||||
|
className={css({
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: 'red.600',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'red.700',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = '/arcade-rooms')}
|
||||||
|
data-action="return-to-lobby"
|
||||||
|
className={css({
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: 'gray.200',
|
||||||
|
color: 'gray.800',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'gray.300',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Return to Lobby
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical details (collapsible) */}
|
||||||
|
<div className={css({ marginTop: '16px', width: '100%', maxWidth: '600px' })}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
data-action="toggle-error-details"
|
||||||
|
className={css({
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'red.700',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
_hover: {
|
||||||
|
color: 'red.900',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{showDetails ? 'Hide' : 'Show'} technical details
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'red.200',
|
||||||
|
borderRadius: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'red.900',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Error: {error.message}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { css } from '../../styled-system/css'
|
||||||
|
|
||||||
|
export interface ErrorToastProps {
|
||||||
|
message: string
|
||||||
|
details?: string
|
||||||
|
onDismiss: () => void
|
||||||
|
autoHideDuration?: number // milliseconds, default 10000 (10s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error toast notification component
|
||||||
|
* Shows prominent error messages to users with optional details
|
||||||
|
*/
|
||||||
|
export function ErrorToast({
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
onDismiss,
|
||||||
|
autoHideDuration = 10000,
|
||||||
|
}: ErrorToastProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoHideDuration > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(onDismiss, 300) // Wait for fade-out animation
|
||||||
|
}, autoHideDuration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [autoHideDuration, onDismiss])
|
||||||
|
|
||||||
|
if (!isVisible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-component="error-toast"
|
||||||
|
className={css({
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '24px',
|
||||||
|
right: '24px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
backgroundColor: 'red.700',
|
||||||
|
color: 'white',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
zIndex: 20000, // Above everything
|
||||||
|
transition: 'all 0.3s ease-out',
|
||||||
|
'@media (max-width: 480px)': {
|
||||||
|
left: '16px',
|
||||||
|
right: '16px',
|
||||||
|
bottom: '16px',
|
||||||
|
maxWidth: 'none',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Header with dismiss button */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '8px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
flex: 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className={css({ fontSize: '20px' })} aria-label="Error">
|
||||||
|
⚠️
|
||||||
|
</span>
|
||||||
|
<strong className={css({ fontSize: '16px', fontWeight: 'bold' })}>Error</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(onDismiss, 300)
|
||||||
|
}}
|
||||||
|
data-action="dismiss-error"
|
||||||
|
className={css({
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0',
|
||||||
|
lineHeight: '1',
|
||||||
|
opacity: 0.7,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
_hover: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
<div className={css({ fontSize: '14px', lineHeight: '1.5', marginBottom: details ? '8px' : '0' })}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional details */}
|
||||||
|
{details && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
data-action="toggle-error-details"
|
||||||
|
className={css({
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 0',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
opacity: 0.8,
|
||||||
|
_hover: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{showDetails ? 'Hide' : 'Show'} technical details
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<pre
|
||||||
|
className={css({
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '200px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{details}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error toast container that manages multiple toasts
|
||||||
|
*/
|
||||||
|
export function ErrorToastContainer({ errors }: { errors: Array<{ id: string; message: string; details?: string }> }) {
|
||||||
|
const [visibleErrors, setVisibleErrors] = useState(errors)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleErrors(errors)
|
||||||
|
}, [errors])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleErrors.map((error, index) => (
|
||||||
|
<ErrorToast
|
||||||
|
key={error.id}
|
||||||
|
message={error.message}
|
||||||
|
details={error.details}
|
||||||
|
onDismiss={() => {
|
||||||
|
setVisibleErrors((prev) => prev.filter((e) => e.id !== error.id))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import { ErrorToast } from '@/components/ErrorToast'
|
||||||
|
|
||||||
|
interface ArcadeError {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
details?: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArcadeErrorContextValue {
|
||||||
|
errors: ArcadeError[]
|
||||||
|
addError: (message: string, details?: string) => void
|
||||||
|
clearError: (id: string) => void
|
||||||
|
clearAllErrors: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArcadeErrorContext = createContext<ArcadeErrorContextValue | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for arcade error management
|
||||||
|
* Manages error toast notifications across arcade games
|
||||||
|
*/
|
||||||
|
export function ArcadeErrorProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [errors, setErrors] = useState<ArcadeError[]>([])
|
||||||
|
|
||||||
|
const addError = useCallback((message: string, details?: string) => {
|
||||||
|
const error: ArcadeError = {
|
||||||
|
id: `error-${Date.now()}-${Math.random()}`,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors((prev) => [...prev, error])
|
||||||
|
|
||||||
|
// Auto-remove after 15 seconds as fallback
|
||||||
|
setTimeout(() => {
|
||||||
|
setErrors((prev) => prev.filter((e) => e.id !== error.id))
|
||||||
|
}, 15000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearError = useCallback((id: string) => {
|
||||||
|
setErrors((prev) => prev.filter((e) => e.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearAllErrors = useCallback(() => {
|
||||||
|
setErrors([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArcadeErrorContext.Provider value={{ errors, addError, clearError, clearAllErrors }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Render error toasts */}
|
||||||
|
<div data-component="arcade-error-toasts">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<ErrorToast
|
||||||
|
key={error.id}
|
||||||
|
message={error.message}
|
||||||
|
details={error.details}
|
||||||
|
onDismiss={() => clearError(error.id)}
|
||||||
|
autoHideDuration={10000}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ArcadeErrorContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access arcade error context
|
||||||
|
*/
|
||||||
|
export function useArcadeError() {
|
||||||
|
const context = useContext(ArcadeErrorContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useArcadeError must be used within ArcadeErrorProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { io, type Socket } from 'socket.io-client'
|
import { io, type Socket } from 'socket.io-client'
|
||||||
import type { GameMove } from '@/lib/arcade/validation'
|
import type { GameMove } from '@/lib/arcade/validation'
|
||||||
|
import { useArcadeError } from '@/contexts/ArcadeErrorContext'
|
||||||
|
|
||||||
export interface ArcadeSocketEvents {
|
export interface ArcadeSocketEvents {
|
||||||
onSessionState?: (data: {
|
onSessionState?: (data: {
|
||||||
|
|
@ -15,6 +16,8 @@ export interface ArcadeSocketEvents {
|
||||||
onSessionEnded?: () => void
|
onSessionEnded?: () => void
|
||||||
onNoActiveSession?: () => void
|
onNoActiveSession?: () => void
|
||||||
onError?: (error: { error: string }) => void
|
onError?: (error: { error: string }) => void
|
||||||
|
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
|
||||||
|
suppressErrorToasts?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseArcadeSocketReturn {
|
export interface UseArcadeSocketReturn {
|
||||||
|
|
@ -36,6 +39,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
const [socket, setSocket] = useState<Socket | null>(null)
|
const [socket, setSocket] = useState<Socket | null>(null)
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const eventsRef = useRef(events)
|
const eventsRef = useRef(events)
|
||||||
|
const { addError } = useArcadeError()
|
||||||
|
|
||||||
// Update events ref when they change
|
// Update events ref when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -59,6 +63,25 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
socketInstance.on('disconnect', () => {
|
socketInstance.on('disconnect', () => {
|
||||||
console.log('[ArcadeSocket] Disconnected')
|
console.log('[ArcadeSocket] Disconnected')
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
|
|
||||||
|
// Show error toast unless suppressed
|
||||||
|
if (!eventsRef.current.suppressErrorToasts) {
|
||||||
|
addError(
|
||||||
|
'Connection lost',
|
||||||
|
'The connection to the game server was lost. Attempting to reconnect...'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socketInstance.on('connect_error', (error) => {
|
||||||
|
console.error('[ArcadeSocket] Connection error', error)
|
||||||
|
|
||||||
|
if (!eventsRef.current.suppressErrorToasts) {
|
||||||
|
addError(
|
||||||
|
'Connection error',
|
||||||
|
`Failed to connect to the game server: ${error.message}\n\nPlease check your internet connection and try refreshing the page.`
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
socketInstance.on('session-state', (data) => {
|
socketInstance.on('session-state', (data) => {
|
||||||
|
|
@ -66,6 +89,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
})
|
})
|
||||||
|
|
||||||
socketInstance.on('no-active-session', () => {
|
socketInstance.on('no-active-session', () => {
|
||||||
|
// Show error toast unless suppressed
|
||||||
|
if (!eventsRef.current.suppressErrorToasts) {
|
||||||
|
addError(
|
||||||
|
'No active session',
|
||||||
|
'No game session was found. Please start a new game or join an existing room.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
eventsRef.current.onNoActiveSession?.()
|
eventsRef.current.onNoActiveSession?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -75,6 +106,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
|
|
||||||
socketInstance.on('move-rejected', (data) => {
|
socketInstance.on('move-rejected', (data) => {
|
||||||
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
|
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
|
||||||
|
|
||||||
|
// Show error toast for move rejections unless suppressed or it's a version conflict
|
||||||
|
if (!eventsRef.current.suppressErrorToasts && !data.versionConflict) {
|
||||||
|
addError(
|
||||||
|
'Move rejected',
|
||||||
|
`Your move was not accepted: ${data.error}\n\nMove type: ${data.move.type}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
eventsRef.current.onMoveRejected?.(data)
|
eventsRef.current.onMoveRejected?.(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -85,6 +125,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||||
|
|
||||||
socketInstance.on('session-error', (data) => {
|
socketInstance.on('session-error', (data) => {
|
||||||
console.error('[ArcadeSocket] Session error', data)
|
console.error('[ArcadeSocket] Session error', data)
|
||||||
|
|
||||||
|
// Show error toast unless suppressed
|
||||||
|
if (!eventsRef.current.suppressErrorToasts) {
|
||||||
|
addError(
|
||||||
|
'Game session error',
|
||||||
|
`Error: ${data.error}\n\nThis usually means there was a problem loading or updating the game session. Please try refreshing the page or returning to the lobby.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
eventsRef.current.onError?.(data)
|
eventsRef.current.onError?.(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue