dev setup
This commit is contained in:
42
apps/website/components/dev/Accordion.tsx
Normal file
42
apps/website/components/dev/Accordion.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface AccordionProps {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function Accordion({ title, icon, children, defaultOpen = false }: AccordionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border border-charcoal-outline rounded-lg overflow-hidden bg-iron-gray/30">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-iron-gray/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="p-3 border-t border-charcoal-outline">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,106 +3,22 @@
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Award,
|
||||
Bell,
|
||||
BellRing,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
LogIn,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
User,
|
||||
Vote,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, LogIn } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
|
||||
type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||
type DemoUrgency = 'silent' | 'toast' | 'modal';
|
||||
// Import our new components
|
||||
import { Accordion } from './Accordion';
|
||||
import { NotificationTypeSection } from './sections/NotificationTypeSection';
|
||||
import { UrgencySection } from './sections/UrgencySection';
|
||||
import { NotificationSendSection } from './sections/NotificationSendSection';
|
||||
import { APIStatusSection } from './sections/APIStatusSection';
|
||||
import { LoginSection } from './sections/LoginSection';
|
||||
|
||||
interface NotificationOption {
|
||||
type: DemoNotificationType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Bell;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface UrgencyOption {
|
||||
urgency: DemoUrgency;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Bell;
|
||||
}
|
||||
|
||||
const notificationOptions: NotificationOption[] = [
|
||||
{
|
||||
type: 'protest_filed',
|
||||
label: 'Protest Against You',
|
||||
description: 'A protest was filed against you',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-red-400',
|
||||
},
|
||||
{
|
||||
type: 'defense_requested',
|
||||
label: 'Defense Requested',
|
||||
description: 'A steward requests your defense',
|
||||
icon: Shield,
|
||||
color: 'text-warning-amber',
|
||||
},
|
||||
{
|
||||
type: 'vote_required',
|
||||
label: 'Vote Required',
|
||||
description: 'You need to vote on a protest',
|
||||
icon: Vote,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
type: 'race_performance_summary',
|
||||
label: 'Race Performance Summary',
|
||||
description: 'Immediate results after main race',
|
||||
icon: TrendingUp,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
type: 'race_final_results',
|
||||
label: 'Race Final Results',
|
||||
description: 'Final results after stewarding closes',
|
||||
icon: Award,
|
||||
color: 'text-warning-amber',
|
||||
},
|
||||
];
|
||||
|
||||
const urgencyOptions: UrgencyOption[] = [
|
||||
{
|
||||
urgency: 'silent',
|
||||
label: 'Silent',
|
||||
description: 'Only shows in notification center',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
urgency: 'toast',
|
||||
label: 'Toast',
|
||||
description: 'Shows a temporary popup',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
urgency: 'modal',
|
||||
label: 'Modal',
|
||||
description: 'Shows blocking modal (may require response)',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
];
|
||||
|
||||
type LoginMode = 'none' | 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
|
||||
// Import types
|
||||
import type { DemoNotificationType, DemoUrgency, LoginMode } from './types';
|
||||
|
||||
export default function DevToolbar() {
|
||||
const router = useRouter();
|
||||
@@ -116,6 +32,12 @@ export default function DevToolbar() {
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
// API Status Monitoring State
|
||||
const [apiStatus, setApiStatus] = useState(() => ApiConnectionMonitor.getInstance().getStatus());
|
||||
const [apiHealth, setApiHealth] = useState(() => ApiConnectionMonitor.getInstance().getHealth());
|
||||
const [circuitBreakers, setCircuitBreakers] = useState(() => CircuitBreakerRegistry.getInstance().getStatus());
|
||||
const [checkingHealth, setCheckingHealth] = useState(false);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
// Sync login mode with actual session state on mount
|
||||
@@ -176,6 +98,101 @@ export default function DevToolbar() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// API Status Monitoring Effects
|
||||
useEffect(() => {
|
||||
const monitor = ApiConnectionMonitor.getInstance();
|
||||
const registry = CircuitBreakerRegistry.getInstance();
|
||||
|
||||
const updateStatus = () => {
|
||||
setApiStatus(monitor.getStatus());
|
||||
setApiHealth(monitor.getHealth());
|
||||
setCircuitBreakers(registry.getStatus());
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateStatus();
|
||||
|
||||
// Listen for status changes
|
||||
monitor.on('connected', updateStatus);
|
||||
monitor.on('disconnected', updateStatus);
|
||||
monitor.on('degraded', updateStatus);
|
||||
monitor.on('success', updateStatus);
|
||||
monitor.on('failure', updateStatus);
|
||||
|
||||
// Poll for updates every 2 seconds
|
||||
const interval = setInterval(updateStatus, 2000);
|
||||
|
||||
return () => {
|
||||
monitor.off('connected', updateStatus);
|
||||
monitor.off('disconnected', updateStatus);
|
||||
monitor.off('degraded', updateStatus);
|
||||
monitor.off('success', updateStatus);
|
||||
monitor.off('failure', updateStatus);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// API Health Check Handler
|
||||
const handleApiHealthCheck = async () => {
|
||||
setCheckingHealth(true);
|
||||
try {
|
||||
const monitor = ApiConnectionMonitor.getInstance();
|
||||
const result = await monitor.performHealthCheck();
|
||||
|
||||
addNotification({
|
||||
type: result.healthy ? 'api_healthy' : 'api_unhealthy',
|
||||
title: result.healthy ? 'API Health Check Passed' : 'API Health Check Failed',
|
||||
message: result.healthy
|
||||
? `API responded in ${result.responseTime}ms`
|
||||
: `Health check failed: ${result.error}`,
|
||||
variant: 'toast',
|
||||
});
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'api_error',
|
||||
title: 'Health Check Error',
|
||||
message: 'Failed to perform health check',
|
||||
variant: 'toast',
|
||||
});
|
||||
} finally {
|
||||
setCheckingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset API Stats
|
||||
const handleResetApiStats = () => {
|
||||
ApiConnectionMonitor.getInstance().reset();
|
||||
CircuitBreakerRegistry.getInstance().resetAll();
|
||||
|
||||
addNotification({
|
||||
type: 'api_reset',
|
||||
title: 'API Stats Reset',
|
||||
message: 'All API connection statistics have been reset',
|
||||
variant: 'toast',
|
||||
});
|
||||
};
|
||||
|
||||
// Test API Error
|
||||
const handleTestApiError = async () => {
|
||||
try {
|
||||
// This will intentionally fail to test error handling
|
||||
const response = await fetch('http://localhost:3001/api/nonexistent', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Test error: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'api_test_error',
|
||||
title: 'Test Error Triggered',
|
||||
message: 'This is a test API error to demonstrate error handling',
|
||||
variant: 'toast',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoLogin = async (role: LoginMode) => {
|
||||
if (role === 'none') return;
|
||||
|
||||
@@ -202,8 +219,7 @@ export default function DevToolbar() {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Demo login failed:', error);
|
||||
alert('Demo login failed. Please check the console for details.');
|
||||
alert('Demo login failed. Please check the API server status.');
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
@@ -219,8 +235,7 @@ export default function DevToolbar() {
|
||||
// Refresh to update all components
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
alert('Logout failed. Please check the console for details.');
|
||||
alert('Logout failed. Please check the API server status.');
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
@@ -283,142 +298,11 @@ export default function DevToolbar() {
|
||||
setLastSent(`${selectedType}-${selectedUrgency}`);
|
||||
setTimeout(() => setLastSent(null), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to send demo notification:', error);
|
||||
} finally {
|
||||
// Silent failure for demo notifications
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const handleSendNotification = async () => {
|
||||
// setSending(true);
|
||||
// try {
|
||||
// const sendNotification = getSendNotificationUseCase();
|
||||
|
||||
// const raceRepository = getRaceRepository();
|
||||
// const leagueRepository = getLeagueRepository();
|
||||
|
||||
// const [allRaces, allLeagues] = await Promise.all([
|
||||
// raceRepository.findAll(),
|
||||
// leagueRepository.findAll(),
|
||||
// ]);
|
||||
|
||||
// const completedRaces = allRaces.filter((race) => race.status === 'completed');
|
||||
// const scheduledRaces = allRaces.filter((race) => race.status === 'scheduled');
|
||||
|
||||
// const primaryRace = completedRaces[0] ?? allRaces[0];
|
||||
// const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace;
|
||||
// const primaryLeague = allLeagues[0];
|
||||
|
||||
// const notificationDeadline =
|
||||
// selectedUrgency === 'modal'
|
||||
// ? new Date(Date.now() + 48 * 60 * 60 * 1000)
|
||||
// : undefined;
|
||||
|
||||
// let title: string;
|
||||
// let body: string;
|
||||
// let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||
// let actionUrl: string;
|
||||
|
||||
// switch (selectedType) {
|
||||
// case 'protest_filed': {
|
||||
// const raceId = primaryRace?.id;
|
||||
// title = '🚨 Protest Filed Against You';
|
||||
// body =
|
||||
// 'A protest has been filed against you for unsafe rejoining during a recent race. Please review the incident details.';
|
||||
// notificationType = 'protest_filed';
|
||||
// actionUrl = raceId ? `/races/${raceId}/stewarding` : '/races';
|
||||
// break;
|
||||
// }
|
||||
// case 'defense_requested': {
|
||||
// const raceId = secondaryRace?.id ?? primaryRace?.id;
|
||||
// title = '⚖️ Defense Requested';
|
||||
// body =
|
||||
// 'A steward has requested your defense regarding a recent incident. Please provide your side of the story within 48 hours.';
|
||||
// notificationType = 'protest_defense_requested';
|
||||
// actionUrl = raceId ? `/races/${raceId}/stewarding` : '/races';
|
||||
// break;
|
||||
// }
|
||||
// case 'vote_required': {
|
||||
// const leagueId = primaryLeague?.id;
|
||||
// title = '🗳️ Your Vote Required';
|
||||
// body =
|
||||
// 'As a league steward, you are required to vote on an open protest. Please review the case and cast your vote.';
|
||||
// notificationType = 'protest_vote_required';
|
||||
// actionUrl = leagueId ? `/leagues/${leagueId}/stewarding` : '/leagues';
|
||||
// break;
|
||||
// }
|
||||
// case 'race_performance_summary': {
|
||||
// const raceId = primaryRace?.id;
|
||||
// const leagueId = primaryLeague?.id;
|
||||
// title = '🏁 Race Complete: Performance Summary';
|
||||
// body =
|
||||
// 'Your Monza Grand Prix race is finished! You finished P1 with 0 incidents. Provisional rating: +25 points. View full results and standings.';
|
||||
// notificationType = 'race_performance_summary';
|
||||
// actionUrl = raceId ? `/races/${raceId}` : '/races';
|
||||
// break;
|
||||
// }
|
||||
// case 'race_final_results': {
|
||||
// const leagueId = primaryLeague?.id;
|
||||
// title = '🏆 Final Results: Monza Grand Prix';
|
||||
// body =
|
||||
// 'Stewarding is now closed. Your final result: P1 (+25 rating). No penalties were applied. View championship standings.';
|
||||
// notificationType = 'race_final_results';
|
||||
// actionUrl = leagueId ? `/leagues/${leagueId}/standings` : '/leagues';
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
|
||||
// const actions =
|
||||
// selectedUrgency === 'modal'
|
||||
// ? selectedType.startsWith('race_')
|
||||
// ? [
|
||||
// { label: selectedType === 'race_performance_summary' ? '🏁 View Race Results' : '🏆 View Standings', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||
// { label: '🎉 Share Achievement', type: 'secondary' as const, actionId: 'share' },
|
||||
// ]
|
||||
// : [
|
||||
// { label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||
// { label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
||||
// ]
|
||||
// : [];
|
||||
|
||||
// await sendNotification.execute({
|
||||
// recipientId: currentDriverId,
|
||||
// type: notificationType,
|
||||
// title,
|
||||
// body,
|
||||
// actionUrl,
|
||||
// urgency: selectedUrgency as NotificationUrgency,
|
||||
// requiresResponse: selectedUrgency === 'modal' && !selectedType.startsWith('race_'),
|
||||
// actions,
|
||||
// data: {
|
||||
// ...(selectedType.startsWith('protest_') ? {
|
||||
// protestId: `demo-protest-${Date.now()}`,
|
||||
// } : {}),
|
||||
// ...(selectedType.startsWith('race_') ? {
|
||||
// raceEventId: `demo-race-event-${Date.now()}`,
|
||||
// sessionId: `demo-session-${Date.now()}`,
|
||||
// position: 1,
|
||||
// positionChange: 0,
|
||||
// incidents: 0,
|
||||
// provisionalRatingChange: 25,
|
||||
// finalRatingChange: 25,
|
||||
// hadPenaltiesApplied: false,
|
||||
// } : {}),
|
||||
// raceId: primaryRace?.id ?? '',
|
||||
// leagueId: primaryLeague?.id ?? '',
|
||||
// ...(notificationDeadline && selectedType.startsWith('protest_') ? { deadline: notificationDeadline } : {}),
|
||||
// },
|
||||
// });
|
||||
|
||||
// setLastSent(`${selectedType}-${selectedUrgency}`);
|
||||
// setTimeout(() => setLastSent(null), 3000);
|
||||
// } catch (error) {
|
||||
// console.error('Failed to send demo notification:', error);
|
||||
// } finally {
|
||||
// setSending(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<button
|
||||
@@ -464,286 +348,62 @@ export default function DevToolbar() {
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Notification Type Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Notification Type
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{notificationOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedType === option.type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => setSelectedType(option.type)}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
||||
${isSelected
|
||||
? 'bg-primary-blue/20 border-primary-blue/50'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isSelected ? 'text-primary-blue' : option.color}`} />
|
||||
<span className={`text-[10px] font-medium ${isSelected ? 'text-primary-blue' : 'text-gray-400'}`}>
|
||||
{option.label.split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Urgency Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BellRing className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Urgency Level
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{urgencyOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedUrgency === option.urgency;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.urgency}
|
||||
onClick={() => setSelectedUrgency(option.urgency)}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
||||
${isSelected
|
||||
? option.urgency === 'modal'
|
||||
? 'bg-red-500/20 border-red-500/50'
|
||||
: option.urgency === 'toast'
|
||||
? 'bg-warning-amber/20 border-warning-amber/50'
|
||||
: 'bg-gray-500/20 border-gray-500/50'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${
|
||||
isSelected
|
||||
? option.urgency === 'modal'
|
||||
? 'text-red-400'
|
||||
: option.urgency === 'toast'
|
||||
? 'text-warning-amber'
|
||||
: 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`} />
|
||||
<span className={`text-[10px] font-medium ${
|
||||
isSelected
|
||||
? option.urgency === 'modal'
|
||||
? 'text-red-400'
|
||||
: option.urgency === 'toast'
|
||||
? 'text-warning-amber'
|
||||
: 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-1">
|
||||
{urgencyOptions.find(o => o.urgency === selectedUrgency)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<button
|
||||
onClick={handleSendNotification}
|
||||
disabled={sending}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all
|
||||
${lastSent
|
||||
? 'bg-performance-green/20 border border-performance-green/30 text-performance-green'
|
||||
: 'bg-primary-blue hover:bg-primary-blue/80 text-white'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Notification Section - Accordion */}
|
||||
<Accordion
|
||||
title="Notifications"
|
||||
icon={<MessageSquare className="w-4 h-4 text-gray-400" />}
|
||||
defaultOpen={true}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : lastSent ? (
|
||||
<>
|
||||
✓ Notification Sent!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="w-4 h-4" />
|
||||
Send Demo Notification
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
||||
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
||||
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Section */}
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<LogIn className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Demo Login
|
||||
</span>
|
||||
<div className="space-y-3">
|
||||
<NotificationTypeSection
|
||||
selectedType={selectedType}
|
||||
onSelectType={setSelectedType}
|
||||
/>
|
||||
<UrgencySection
|
||||
selectedUrgency={selectedUrgency}
|
||||
onSelectUrgency={setSelectedUrgency}
|
||||
/>
|
||||
<NotificationSendSection
|
||||
selectedType={selectedType}
|
||||
selectedUrgency={selectedUrgency}
|
||||
sending={sending}
|
||||
lastSent={lastSent}
|
||||
onSend={handleSendNotification}
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Driver Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('driver')}
|
||||
disabled={loggingIn || loginMode === 'driver'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'driver'
|
||||
? 'bg-primary-blue/20 border-primary-blue/50 text-primary-blue'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
{loginMode === 'driver' ? '✓ Driver' : 'Login as Driver'}
|
||||
</button>
|
||||
{/* API Status Section - Accordion */}
|
||||
<Accordion
|
||||
title="API Status"
|
||||
icon={<Activity className="w-4 h-4 text-gray-400" />}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<APIStatusSection
|
||||
apiStatus={apiStatus}
|
||||
apiHealth={apiHealth}
|
||||
circuitBreakers={circuitBreakers}
|
||||
checkingHealth={checkingHealth}
|
||||
onHealthCheck={handleApiHealthCheck}
|
||||
onResetStats={handleResetApiStats}
|
||||
onTestError={handleTestApiError}
|
||||
/>
|
||||
</Accordion>
|
||||
|
||||
{/* League Owner Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('league-owner')}
|
||||
disabled={loggingIn || loginMode === 'league-owner'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'league-owner'
|
||||
? 'bg-purple-500/20 border-purple-500/50 text-purple-400'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span className="text-xs">👑</span>
|
||||
{loginMode === 'league-owner' ? '✓ League Owner' : 'Login as League Owner'}
|
||||
</button>
|
||||
|
||||
{/* League Steward Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('league-steward')}
|
||||
disabled={loggingIn || loginMode === 'league-steward'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'league-steward'
|
||||
? 'bg-amber-500/20 border-amber-500/50 text-amber-400'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{loginMode === 'league-steward' ? '✓ Steward' : 'Login as Steward'}
|
||||
</button>
|
||||
|
||||
{/* League Admin Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('league-admin')}
|
||||
disabled={loggingIn || loginMode === 'league-admin'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'league-admin'
|
||||
? 'bg-red-500/20 border-red-500/50 text-red-400'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span className="text-xs">⚙️</span>
|
||||
{loginMode === 'league-admin' ? '✓ Admin' : 'Login as Admin'}
|
||||
</button>
|
||||
|
||||
{/* Sponsor Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('sponsor')}
|
||||
disabled={loggingIn || loginMode === 'sponsor'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'sponsor'
|
||||
? 'bg-performance-green/20 border-performance-green/50 text-performance-green'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
{loginMode === 'sponsor' ? '✓ Sponsor' : 'Login as Sponsor'}
|
||||
</button>
|
||||
|
||||
{/* System Owner Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('system-owner')}
|
||||
disabled={loggingIn || loginMode === 'system-owner'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'system-owner'
|
||||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span className="text-xs">👑</span>
|
||||
{loginMode === 'system-owner' ? '✓ System Owner' : 'Login as System Owner'}
|
||||
</button>
|
||||
|
||||
{/* Super Admin Login */}
|
||||
<button
|
||||
onClick={() => handleDemoLogin('super-admin')}
|
||||
disabled={loggingIn || loginMode === 'super-admin'}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${loginMode === 'super-admin'
|
||||
? 'bg-pink-500/20 border-pink-500/50 text-pink-400'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span className="text-xs">⚡</span>
|
||||
{loginMode === 'super-admin' ? '✓ Super Admin' : 'Login as Super Admin'}
|
||||
</button>
|
||||
|
||||
{loginMode !== 'none' && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loggingIn}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm font-medium hover:bg-red-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-gray-600 mt-2">
|
||||
Test different user roles for demo purposes. Dashboard works for all roles.
|
||||
</p>
|
||||
</div>
|
||||
{/* Login Section - Accordion */}
|
||||
<Accordion
|
||||
title="Demo Login"
|
||||
icon={<LogIn className="w-4 h-4 text-gray-400" />}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<LoginSection
|
||||
loginMode={loginMode}
|
||||
loggingIn={loggingIn}
|
||||
onDemoLogin={handleDemoLogin}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -755,4 +415,4 @@ export default function DevToolbar() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
145
apps/website/components/dev/sections/APIStatusSection.tsx
Normal file
145
apps/website/components/dev/sections/APIStatusSection.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, Wifi, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||
|
||||
interface APIStatusSectionProps {
|
||||
apiStatus: string;
|
||||
apiHealth: any;
|
||||
circuitBreakers: Record<string, any>;
|
||||
checkingHealth: boolean;
|
||||
onHealthCheck: () => void;
|
||||
onResetStats: () => void;
|
||||
onTestError: () => void;
|
||||
}
|
||||
|
||||
export function APIStatusSection({
|
||||
apiStatus,
|
||||
apiHealth,
|
||||
circuitBreakers,
|
||||
checkingHealth,
|
||||
onHealthCheck,
|
||||
onResetStats,
|
||||
onTestError
|
||||
}: APIStatusSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
API Status
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className={`flex items-center justify-between p-2 rounded-lg mb-2 ${
|
||||
apiStatus === 'connected' ? 'bg-green-500/10 border border-green-500/30' :
|
||||
apiStatus === 'degraded' ? 'bg-yellow-500/10 border border-yellow-500/30' :
|
||||
'bg-red-500/10 border border-red-500/30'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className={`w-4 h-4 ${
|
||||
apiStatus === 'connected' ? 'text-green-400' :
|
||||
apiStatus === 'degraded' ? 'text-yellow-400' :
|
||||
'text-red-400'
|
||||
}`} />
|
||||
<span className="text-sm font-semibold text-white">{apiStatus.toUpperCase()}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{apiHealth.successfulRequests}/{apiHealth.totalRequests} req
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reliability */}
|
||||
<div className="flex items-center justify-between text-xs mb-2">
|
||||
<span className="text-gray-500">Reliability</span>
|
||||
<span className={`font-bold ${
|
||||
apiHealth.totalRequests === 0 ? 'text-gray-500' :
|
||||
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.95 ? 'text-green-400' :
|
||||
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.8 ? 'text-yellow-400' :
|
||||
'text-red-400'
|
||||
}`}>
|
||||
{apiHealth.totalRequests === 0 ? 'N/A' :
|
||||
((apiHealth.successfulRequests / apiHealth.totalRequests) * 100).toFixed(1) + '%'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Response Time */}
|
||||
<div className="flex items-center justify-between text-xs mb-2">
|
||||
<span className="text-gray-500">Avg Response</span>
|
||||
<span className="text-blue-400 font-mono">
|
||||
{apiHealth.averageResponseTime.toFixed(0)}ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Consecutive Failures */}
|
||||
{apiHealth.consecutiveFailures > 0 && (
|
||||
<div className="flex items-center justify-between text-xs mb-2 bg-red-500/10 rounded px-2 py-1">
|
||||
<span className="text-red-400">Consecutive Failures</span>
|
||||
<span className="text-red-400 font-bold">{apiHealth.consecutiveFailures}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Circuit Breakers */}
|
||||
<div className="mt-2">
|
||||
<div className="text-[10px] text-gray-500 mb-1">Circuit Breakers:</div>
|
||||
{Object.keys(circuitBreakers).length === 0 ? (
|
||||
<div className="text-[10px] text-gray-500 italic">None active</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-16 overflow-auto">
|
||||
{Object.entries(circuitBreakers).map(([endpoint, status]: [string, any]) => (
|
||||
<div key={endpoint} className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-gray-400 truncate flex-1">{endpoint.split('/').pop() || endpoint}</span>
|
||||
<span className={`px-1 rounded ${
|
||||
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
|
||||
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
|
||||
'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{status.state}
|
||||
</span>
|
||||
{status.failures > 0 && (
|
||||
<span className="text-red-400 ml-1">({status.failures})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Actions */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<button
|
||||
onClick={onHealthCheck}
|
||||
disabled={checkingHealth}
|
||||
className="px-2 py-1.5 bg-primary-blue hover:bg-primary-blue/80 text-white text-xs rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${checkingHealth ? 'animate-spin' : ''}`} />
|
||||
Health Check
|
||||
</button>
|
||||
<button
|
||||
onClick={onResetStats}
|
||||
className="px-2 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 text-xs rounded transition-colors border border-charcoal-outline"
|
||||
>
|
||||
Reset Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 mt-2">
|
||||
<button
|
||||
onClick={onTestError}
|
||||
className="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Test Error Handler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-gray-600 mt-2">
|
||||
Last Check: {apiHealth.lastCheck ? new Date(apiHealth.lastCheck).toLocaleTimeString() : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/website/components/dev/sections/LoginSection.tsx
Normal file
79
apps/website/components/dev/sections/LoginSection.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { LogIn, LogOut, User, Shield, Building2 } from 'lucide-react';
|
||||
import type { LoginMode } from '../types';
|
||||
|
||||
interface LoginSectionProps {
|
||||
loginMode: LoginMode;
|
||||
loggingIn: boolean;
|
||||
onDemoLogin: (role: LoginMode) => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function LoginSection({ loginMode, loggingIn, onDemoLogin, onLogout }: LoginSectionProps) {
|
||||
const loginOptions = [
|
||||
{ mode: 'driver' as LoginMode, label: 'Driver', icon: User, color: 'primary-blue', emoji: null },
|
||||
{ mode: 'league-owner' as LoginMode, label: 'League Owner', icon: null, color: 'purple-500', emoji: '👑' },
|
||||
{ mode: 'league-steward' as LoginMode, label: 'Steward', icon: Shield, color: 'amber-500', emoji: null },
|
||||
{ mode: 'league-admin' as LoginMode, label: 'Admin', icon: null, color: 'red-500', emoji: '⚙️' },
|
||||
{ mode: 'sponsor' as LoginMode, label: 'Sponsor', icon: Building2, color: 'performance-green', emoji: null },
|
||||
{ mode: 'system-owner' as LoginMode, label: 'System Owner', icon: null, color: 'indigo-500', emoji: '👑' },
|
||||
{ mode: 'super-admin' as LoginMode, label: 'Super Admin', icon: null, color: 'pink-500', emoji: '⚡' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<LogIn className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Demo Login
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{loginOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = loginMode === option.mode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
onClick={() => onDemoLogin(option.mode)}
|
||||
disabled={loggingIn || isSelected}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? `bg-${option.color}/20 border-${option.color}/50 text-${option.color}`
|
||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
{option.emoji ? (
|
||||
<span className="text-xs">{option.emoji}</span>
|
||||
) : Icon ? (
|
||||
<Icon className="w-4 h-4" />
|
||||
) : null}
|
||||
{isSelected ? `✓ ${option.label}` : `Login as ${option.label}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{loginMode !== 'none' && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
disabled={loggingIn}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm font-medium hover:bg-red-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-gray-600 mt-2">
|
||||
Test different user roles for demo purposes. Dashboard works for all roles.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||
import type { DemoNotificationType, DemoUrgency } from '../types';
|
||||
|
||||
interface NotificationSendSectionProps {
|
||||
selectedType: DemoNotificationType;
|
||||
selectedUrgency: DemoUrgency;
|
||||
sending: boolean;
|
||||
lastSent: string | null;
|
||||
onSend: () => void;
|
||||
}
|
||||
|
||||
export function NotificationSendSection({
|
||||
selectedType,
|
||||
selectedUrgency,
|
||||
sending,
|
||||
lastSent,
|
||||
onSend
|
||||
}: NotificationSendSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={sending}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all
|
||||
${lastSent
|
||||
? 'bg-performance-green/20 border border-performance-green/30 text-performance-green'
|
||||
: 'bg-primary-blue hover:bg-primary-blue/80 text-white'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : lastSent ? (
|
||||
<>
|
||||
✓ Notification Sent!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="w-4 h-4" />
|
||||
Send Demo Notification
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline mt-2">
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
||||
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
||||
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { MessageSquare, AlertTriangle, Shield, Vote, TrendingUp, Award } from 'lucide-react';
|
||||
import type { DemoNotificationType } from '../types';
|
||||
|
||||
interface NotificationOption {
|
||||
type: DemoNotificationType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface NotificationTypeSectionProps {
|
||||
selectedType: DemoNotificationType;
|
||||
onSelectType: (type: DemoNotificationType) => void;
|
||||
}
|
||||
|
||||
export const notificationOptions: NotificationOption[] = [
|
||||
{
|
||||
type: 'protest_filed',
|
||||
label: 'Protest Against You',
|
||||
description: 'A protest was filed against you',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-red-400',
|
||||
},
|
||||
{
|
||||
type: 'defense_requested',
|
||||
label: 'Defense Requested',
|
||||
description: 'A steward requests your defense',
|
||||
icon: Shield,
|
||||
color: 'text-warning-amber',
|
||||
},
|
||||
{
|
||||
type: 'vote_required',
|
||||
label: 'Vote Required',
|
||||
description: 'You need to vote on a protest',
|
||||
icon: Vote,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
type: 'race_performance_summary',
|
||||
label: 'Race Performance Summary',
|
||||
description: 'Immediate results after main race',
|
||||
icon: TrendingUp,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
type: 'race_final_results',
|
||||
label: 'Race Final Results',
|
||||
description: 'Final results after stewarding closes',
|
||||
icon: Award,
|
||||
color: 'text-warning-amber',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationTypeSection({ selectedType, onSelectType }: NotificationTypeSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Notification Type
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{notificationOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedType === option.type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => onSelectType(option.type)}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
||||
${isSelected
|
||||
? 'bg-primary-blue/20 border-primary-blue/50'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isSelected ? 'text-primary-blue' : option.color}`} />
|
||||
<span className={`text-[10px] font-medium ${isSelected ? 'text-primary-blue' : 'text-gray-400'}`}>
|
||||
{option.label.split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
apps/website/components/dev/sections/UrgencySection.tsx
Normal file
99
apps/website/components/dev/sections/UrgencySection.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { Bell, BellRing, AlertCircle } from 'lucide-react';
|
||||
import type { DemoUrgency } from '../types';
|
||||
|
||||
interface UrgencyOption {
|
||||
urgency: DemoUrgency;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
}
|
||||
|
||||
interface UrgencySectionProps {
|
||||
selectedUrgency: DemoUrgency;
|
||||
onSelectUrgency: (urgency: DemoUrgency) => void;
|
||||
}
|
||||
|
||||
export const urgencyOptions: UrgencyOption[] = [
|
||||
{
|
||||
urgency: 'silent',
|
||||
label: 'Silent',
|
||||
description: 'Only shows in notification center',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
urgency: 'toast',
|
||||
label: 'Toast',
|
||||
description: 'Shows a temporary popup',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
urgency: 'modal',
|
||||
label: 'Modal',
|
||||
description: 'Shows blocking modal (may require response)',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
];
|
||||
|
||||
export function UrgencySection({ selectedUrgency, onSelectUrgency }: UrgencySectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BellRing className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Urgency Level
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{urgencyOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedUrgency === option.urgency;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.urgency}
|
||||
onClick={() => onSelectUrgency(option.urgency)}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
||||
${isSelected
|
||||
? option.urgency === 'modal'
|
||||
? 'bg-red-500/20 border-red-500/50'
|
||||
: option.urgency === 'toast'
|
||||
? 'bg-warning-amber/20 border-warning-amber/50'
|
||||
: 'bg-gray-500/20 border-gray-500/50'
|
||||
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${
|
||||
isSelected
|
||||
? option.urgency === 'modal'
|
||||
? 'text-red-400'
|
||||
: option.urgency === 'toast'
|
||||
? 'text-warning-amber'
|
||||
: 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`} />
|
||||
<span className={`text-[10px] font-medium ${
|
||||
isSelected
|
||||
? option.urgency === 'modal'
|
||||
? 'text-red-400'
|
||||
: option.urgency === 'toast'
|
||||
? 'text-warning-amber'
|
||||
: 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-1">
|
||||
{urgencyOptions.find(o => o.urgency === selectedUrgency)?.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/website/components/dev/types.ts
Normal file
20
apps/website/components/dev/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||
|
||||
export type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||
export type DemoUrgency = 'silent' | 'toast' | 'modal';
|
||||
export type LoginMode = 'none' | 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
|
||||
|
||||
export interface NotificationOption {
|
||||
type: DemoNotificationType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface UrgencyOption {
|
||||
urgency: DemoUrgency;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
}
|
||||
155
apps/website/components/errors/ApiErrorBoundary.tsx
Normal file
155
apps/website/components/errors/ApiErrorBoundary.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
import { DevErrorPanel } from './DevErrorPanel';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: ApiError) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: ApiError | null;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary for API-related errors
|
||||
* Catches errors from API calls and displays appropriate UI
|
||||
*/
|
||||
export class ApiErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
// Only handle ApiError instances
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
}
|
||||
// Re-throw non-API errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
if (error instanceof ApiError) {
|
||||
// Report to connection monitor
|
||||
connectionMonitor.recordFailure(error);
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
|
||||
// For connectivity errors in production, don't show error boundary UI
|
||||
// These are handled by the notification system
|
||||
if (error.isConnectivityIssue() && !this.state.isDev) {
|
||||
// Reset error state so boundary doesn't block UI
|
||||
setTimeout(() => this.resetError(), 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
// Listen for connection status changes
|
||||
const monitor = connectionMonitor;
|
||||
|
||||
monitor.on('disconnected', this.handleDisconnected);
|
||||
monitor.on('degraded', this.handleDegraded);
|
||||
monitor.on('connected', this.handleConnected);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
const monitor = connectionMonitor;
|
||||
|
||||
monitor.off('disconnected', this.handleDisconnected);
|
||||
monitor.off('degraded', this.handleDegraded);
|
||||
monitor.off('connected', this.handleConnected);
|
||||
}
|
||||
|
||||
private handleDisconnected = (): void => {
|
||||
// Connection status handled by notification system
|
||||
};
|
||||
|
||||
private handleDegraded = (): void => {
|
||||
// Connection status handled by notification system
|
||||
};
|
||||
|
||||
private handleConnected = (): void => {
|
||||
// Connection status handled by notification system
|
||||
};
|
||||
|
||||
resetError = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Show different UI based on environment
|
||||
if (this.state.isDev) {
|
||||
return (
|
||||
<DevErrorPanel
|
||||
error={this.state.error}
|
||||
onReset={this.resetError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={this.state.error}
|
||||
onRetry={this.resetError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-based alternative for functional components
|
||||
*/
|
||||
export function useApiErrorBoundary() {
|
||||
const [error, setError] = React.useState<ApiError | null>(null);
|
||||
const [isDev] = React.useState(process.env.NODE_ENV === 'development');
|
||||
|
||||
const handleError = (err: ApiError) => {
|
||||
setError(err);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return {
|
||||
error,
|
||||
isDev,
|
||||
handleError,
|
||||
reset,
|
||||
ErrorBoundary: ({ children }: { children: ReactNode }) => (
|
||||
<ApiErrorBoundary onError={handleError}>
|
||||
{children}
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
};
|
||||
}
|
||||
329
apps/website/components/errors/ApiStatusToolbar.tsx
Normal file
329
apps/website/components/errors/ApiStatusToolbar.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ApiConnectionMonitor, ConnectionStatus } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
import {
|
||||
Activity,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
Shield,
|
||||
Clock,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ApiStatusToolbarProps {
|
||||
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left';
|
||||
autoHide?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Development toolbar showing real-time API connection status
|
||||
* Integrates with existing DevToolbar or works standalone
|
||||
*/
|
||||
export function ApiStatusToolbar({ position = 'bottom-right', autoHide = false }: ApiStatusToolbarProps) {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [health, setHealth] = useState(ApiConnectionMonitor.getInstance().getHealth());
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const monitor = ApiConnectionMonitor.getInstance();
|
||||
const registry = CircuitBreakerRegistry.getInstance();
|
||||
|
||||
const updateState = () => {
|
||||
setStatus(monitor.getStatus());
|
||||
setHealth(monitor.getHealth());
|
||||
};
|
||||
|
||||
// Initial state
|
||||
updateState();
|
||||
|
||||
// Listen for events
|
||||
monitor.on('connected', updateState);
|
||||
monitor.on('disconnected', updateState);
|
||||
monitor.on('degraded', updateState);
|
||||
monitor.on('success', updateState);
|
||||
monitor.on('failure', updateState);
|
||||
|
||||
// Auto-hide logic
|
||||
if (autoHide) {
|
||||
const hideTimer = setTimeout(() => setShow(false), 5000);
|
||||
const showOnInteraction = () => setShow(true);
|
||||
|
||||
document.addEventListener('mousemove', showOnInteraction);
|
||||
document.addEventListener('click', showOnInteraction);
|
||||
|
||||
return () => {
|
||||
clearTimeout(hideTimer);
|
||||
document.removeEventListener('mousemove', showOnInteraction);
|
||||
document.removeEventListener('click', showOnInteraction);
|
||||
monitor.off('connected', updateState);
|
||||
monitor.off('disconnected', updateState);
|
||||
monitor.off('degraded', updateState);
|
||||
monitor.off('success', updateState);
|
||||
monitor.off('failure', updateState);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
monitor.off('connected', updateState);
|
||||
monitor.off('disconnected', updateState);
|
||||
monitor.off('degraded', updateState);
|
||||
monitor.off('success', updateState);
|
||||
monitor.off('failure', updateState);
|
||||
};
|
||||
}, [autoHide]);
|
||||
|
||||
const handleHealthCheck = async () => {
|
||||
const monitor = ApiConnectionMonitor.getInstance();
|
||||
await monitor.performHealthCheck();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
ApiConnectionMonitor.getInstance().reset();
|
||||
CircuitBreakerRegistry.getInstance().resetAll();
|
||||
};
|
||||
|
||||
const getReliabilityColor = (reliability: number) => {
|
||||
if (reliability >= 95) return 'text-green-400';
|
||||
if (reliability >= 80) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||
case 'degraded':
|
||||
return <AlertTriangle className="w-4 h-4 text-yellow-400" />;
|
||||
case 'disconnected':
|
||||
return <WifiOff className="w-4 h-4 text-red-400" />;
|
||||
case 'checking':
|
||||
return <RefreshCw className="w-4 h-4 animate-spin text-blue-400" />;
|
||||
default:
|
||||
return <Wifi className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'connected': return 'bg-green-500/20 border-green-500/40';
|
||||
case 'degraded': return 'bg-yellow-500/20 border-yellow-500/40';
|
||||
case 'disconnected': return 'bg-red-500/20 border-red-500/40';
|
||||
default: return 'bg-gray-500/20 border-gray-500/40';
|
||||
}
|
||||
};
|
||||
|
||||
const reliability = ((health.successfulRequests / Math.max(health.totalRequests, 1)) * 100).toFixed(1);
|
||||
|
||||
if (!show) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShow(true)}
|
||||
className={`fixed p-2 bg-iron-gray border border-charcoal-outline rounded-lg shadow-lg hover:bg-charcoal-outline transition-all ${
|
||||
position === 'bottom-right' ? 'bottom-4 right-4' :
|
||||
position === 'top-right' ? 'top-4 right-4' :
|
||||
position === 'bottom-left' ? 'bottom-4 left-4' :
|
||||
'top-4 left-4'
|
||||
}`}
|
||||
>
|
||||
<Activity className="w-5 h-5 text-primary-blue" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed z-50 transition-all ${
|
||||
position === 'bottom-right' ? 'bottom-4 right-4' :
|
||||
position === 'top-right' ? 'top-4 right-4' :
|
||||
position === 'bottom-left' ? 'bottom-4 left-4' :
|
||||
'top-4 left-4'
|
||||
}`}>
|
||||
{/* Compact Status Indicator */}
|
||||
{!expanded ? (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border shadow-lg backdrop-blur-md transition-all hover:scale-105 ${getStatusColor()}`}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-semibold text-white">{status.toUpperCase()}</span>
|
||||
<span className={`text-xs ${getReliabilityColor(parseFloat(reliability))}`}>
|
||||
{reliability}%
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
/* Expanded Panel */
|
||||
<div className={`w-80 rounded-lg border shadow-2xl backdrop-blur-md overflow-hidden ${getStatusColor()}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-iron-gray/80 border-b border-charcoal-outline px-3 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs font-bold text-white">API STATUS</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleHealthCheck}
|
||||
className="p-1 hover:bg-charcoal-outline rounded transition-colors"
|
||||
title="Run Health Check"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1 hover:bg-charcoal-outline rounded transition-colors"
|
||||
title="Reset Stats"
|
||||
>
|
||||
<span className="text-xs text-gray-400 hover:text-white">R</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="p-1 hover:bg-charcoal-outline rounded transition-colors"
|
||||
>
|
||||
<span className="text-xs text-gray-400 hover:text-white">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-3 py-2 space-y-2 bg-deep-graphite/90">
|
||||
{/* Status Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Status</span>
|
||||
<span className={`text-xs font-bold uppercase ${status === 'connected' ? 'text-green-400' : status === 'degraded' ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reliability */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Reliability</span>
|
||||
<span className={`text-xs font-bold ${getReliabilityColor(parseFloat(reliability))}`}>
|
||||
{reliability}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Request Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-iron-gray/50 rounded p-1">
|
||||
<div className="text-[10px] text-gray-400">Total</div>
|
||||
<div className="text-sm font-bold text-white">{health.totalRequests}</div>
|
||||
</div>
|
||||
<div className="bg-iron-gray/50 rounded p-1">
|
||||
<div className="text-[10px] text-gray-400">Success</div>
|
||||
<div className="text-sm font-bold text-green-400">{health.successfulRequests}</div>
|
||||
</div>
|
||||
<div className="bg-iron-gray/50 rounded p-1">
|
||||
<div className="text-[10px] text-gray-400">Failed</div>
|
||||
<div className="text-sm font-bold text-red-400">{health.failedRequests}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">Avg Response</span>
|
||||
<span className="text-xs font-mono text-blue-400">
|
||||
{health.averageResponseTime.toFixed(0)}ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Consecutive Failures */}
|
||||
{health.consecutiveFailures > 0 && (
|
||||
<div className="flex items-center justify-between bg-red-500/10 rounded px-2 py-1">
|
||||
<span className="text-xs text-red-400">Consecutive Failures</span>
|
||||
<span className="text-xs font-bold text-red-400">{health.consecutiveFailures}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Circuit Breakers */}
|
||||
<div className="border-t border-charcoal-outline pt-2">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Shield className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-[10px] text-gray-400 font-bold">CIRCUIT BREAKERS</span>
|
||||
</div>
|
||||
<CircuitBreakerStatus />
|
||||
</div>
|
||||
|
||||
{/* Last Check */}
|
||||
<div className="border-t border-charcoal-outline pt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] text-gray-500">Last Check</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono">
|
||||
{health.lastCheck ? new Date(health.lastCheck).toLocaleTimeString() : 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleHealthCheck}
|
||||
className="px-2 py-1 bg-primary-blue hover:bg-primary-blue/80 text-white text-xs rounded transition-colors"
|
||||
>
|
||||
Check Health
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const monitor = ApiConnectionMonitor.getInstance();
|
||||
const report = monitor.getDebugReport();
|
||||
alert(report);
|
||||
}}
|
||||
className="px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 text-xs rounded transition-colors border border-charcoal-outline"
|
||||
>
|
||||
Debug Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker Status Component
|
||||
*/
|
||||
function CircuitBreakerStatus() {
|
||||
const [status, setStatus] = useState(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
|
||||
useEffect(() => {
|
||||
const registry = CircuitBreakerRegistry.getInstance();
|
||||
|
||||
// Poll for updates every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
setStatus(registry.getStatus());
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const entries = Object.entries(status);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-[10px] text-gray-500 italic">No active circuit breakers</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 max-h-20 overflow-auto">
|
||||
{entries.map(([endpoint, breaker]) => (
|
||||
<div key={endpoint} className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-gray-400 truncate flex-1">{endpoint.split('/').pop() || endpoint}</span>
|
||||
<span className={`px-1 rounded ${
|
||||
breaker.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
|
||||
breaker.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
|
||||
'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{breaker.state}
|
||||
</span>
|
||||
{breaker.failures > 0 && (
|
||||
<span className="text-red-400 ml-1">({breaker.failures})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
apps/website/components/errors/DevErrorPanel.tsx
Normal file
347
apps/website/components/errors/DevErrorPanel.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, RefreshCw, Copy, Terminal, Activity, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface DevErrorPanelProps {
|
||||
error: ApiError;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer-focused error panel with detailed debugging information
|
||||
*/
|
||||
export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
|
||||
const [connectionStatus, setConnectionStatus] = useState(connectionMonitor.getHealth());
|
||||
const [circuitBreakers, setCircuitBreakers] = useState(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Update status on mount
|
||||
const health = connectionMonitor.getHealth();
|
||||
setConnectionStatus(health);
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
|
||||
// Listen for status changes
|
||||
const handleStatusChange = () => {
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
};
|
||||
|
||||
connectionMonitor.on('success', handleStatusChange);
|
||||
connectionMonitor.on('failure', handleStatusChange);
|
||||
connectionMonitor.on('connected', handleStatusChange);
|
||||
connectionMonitor.on('disconnected', handleStatusChange);
|
||||
connectionMonitor.on('degraded', handleStatusChange);
|
||||
|
||||
return () => {
|
||||
connectionMonitor.off('success', handleStatusChange);
|
||||
connectionMonitor.off('failure', handleStatusChange);
|
||||
connectionMonitor.off('connected', handleStatusChange);
|
||||
connectionMonitor.off('disconnected', handleStatusChange);
|
||||
connectionMonitor.off('degraded', handleStatusChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const debugInfo = {
|
||||
error: {
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.context,
|
||||
stack: error.stack,
|
||||
},
|
||||
connection: connectionStatus,
|
||||
circuitBreakers,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Silent failure for clipboard operations
|
||||
}
|
||||
};
|
||||
|
||||
const triggerHealthCheck = async () => {
|
||||
await connectionMonitor.performHealthCheck();
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
};
|
||||
|
||||
const resetCircuitBreakers = () => {
|
||||
CircuitBreakerRegistry.getInstance().resetAll();
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
};
|
||||
|
||||
const getSeverityColor = (type: string) => {
|
||||
switch (error.getSeverity()) {
|
||||
case 'error': return 'bg-red-500/20 text-red-400 border-red-500/40';
|
||||
case 'warn': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40';
|
||||
case 'info': return 'bg-blue-500/20 text-blue-400 border-blue-500/40';
|
||||
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/40';
|
||||
}
|
||||
};
|
||||
|
||||
const reliability = connectionMonitor.getReliability();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-auto bg-deep-graphite p-4 font-mono text-sm">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="w-5 h-5 text-primary-blue" />
|
||||
<h2 className="text-lg font-bold text-white">API Error Debug Panel</h2>
|
||||
<span className={`px-2 py-1 rounded border text-xs ${getSeverityColor(error.type)}`}>
|
||||
{error.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="px-3 py-1 bg-iron-gray hover:bg-charcoal-outline border border-charcoal-outline rounded text-gray-300 flex items-center gap-2"
|
||||
title="Copy debug info"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-3 py-1 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Error Details
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Type:</span>
|
||||
<span className="col-span-2 text-red-400 font-bold">{error.type}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Message:</span>
|
||||
<span className="col-span-2 text-gray-300">{error.message}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Endpoint:</span>
|
||||
<span className="col-span-2 text-blue-400">{error.context.endpoint || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Method:</span>
|
||||
<span className="col-span-2 text-yellow-400">{error.context.method || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className="col-span-2">{error.context.statusCode || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Retry Count:</span>
|
||||
<span className="col-span-2">{error.context.retryCount || 0}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Timestamp:</span>
|
||||
<span className="col-span-2 text-gray-500">{error.context.timestamp}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Retryable:</span>
|
||||
<span className={`col-span-2 ${error.isRetryable() ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{error.isRetryable() ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Connectivity:</span>
|
||||
<span className={`col-span-2 ${error.isConnectivityIssue() ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{error.isConnectivityIssue() ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
{error.context.troubleshooting && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Troubleshoot:</span>
|
||||
<span className="col-span-2 text-yellow-400">{error.context.troubleshooting}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Connection Health
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={`col-span-2 font-bold ${
|
||||
connectionStatus.status === 'connected' ? 'text-green-400' :
|
||||
connectionStatus.status === 'degraded' ? 'text-yellow-400' :
|
||||
'text-red-400'
|
||||
}`}>
|
||||
{connectionStatus.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Reliability:</span>
|
||||
<span className="col-span-2">{reliability.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Total Requests:</span>
|
||||
<span className="col-span-2">{connectionStatus.totalRequests}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Successful:</span>
|
||||
<span className="col-span-2 text-green-400">{connectionStatus.successfulRequests}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Failed:</span>
|
||||
<span className="col-span-2 text-red-400">{connectionStatus.failedRequests}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Consecutive Failures:</span>
|
||||
<span className="col-span-2">{connectionStatus.consecutiveFailures}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Avg Response:</span>
|
||||
<span className="col-span-2">{connectionStatus.averageResponseTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<span className="text-gray-500">Last Check:</span>
|
||||
<span className="col-span-2 text-gray-500">
|
||||
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Circuit Breakers */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||
<span className="text-lg">⚡</span>
|
||||
Circuit Breakers
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{Object.keys(circuitBreakers).length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-4">No circuit breakers active</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-xs max-h-48 overflow-auto">
|
||||
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
|
||||
<div key={endpoint} className="flex items-center justify-between p-2 bg-deep-graphite rounded border border-charcoal-outline">
|
||||
<span className="text-blue-400 truncate flex-1">{endpoint}</span>
|
||||
<span className={`px-2 py-1 rounded ${
|
||||
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
|
||||
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
|
||||
'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{status.state}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-2">{status.failures} failures</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
|
||||
Actions
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<button
|
||||
onClick={triggerHealthCheck}
|
||||
className="w-full px-3 py-2 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Run Health Check
|
||||
</button>
|
||||
<button
|
||||
onClick={resetCircuitBreakers}
|
||||
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-lg">🔄</span>
|
||||
Reset Circuit Breakers
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
connectionMonitor.reset();
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-lg">🗑️</span>
|
||||
Reset Connection Stats
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Fixes */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
|
||||
Quick Fixes
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-xs">
|
||||
<div className="text-gray-400">Common solutions:</div>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-300">
|
||||
<li>Check API server is running</li>
|
||||
<li>Verify CORS configuration</li>
|
||||
<li>Check environment variables</li>
|
||||
<li>Review network connectivity</li>
|
||||
<li>Check API rate limits</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw Error */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
|
||||
Raw Error
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<pre className="text-xs text-gray-400 overflow-auto max-h-32 bg-deep-graphite p-2 rounded">
|
||||
{JSON.stringify({
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.context,
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Console Output */}
|
||||
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Console Output
|
||||
</div>
|
||||
<div className="p-4 bg-deep-graphite font-mono text-xs">
|
||||
<div className="text-gray-500 mb-2">{'>'} {error.getDeveloperMessage()}</div>
|
||||
<div className="text-gray-600">Check browser console for full stack trace and additional debug info.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
apps/website/components/errors/ErrorDisplay.tsx
Normal file
146
apps/website/components/errors/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-friendly error display for production environments
|
||||
*/
|
||||
export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) {
|
||||
const router = useRouter();
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
const userMessage = error.getUserMessage();
|
||||
const isConnectivity = error.isConnectivityIssue();
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (onRetry) {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
onRetry();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-500/20 rounded-lg">
|
||||
{isConnectivity ? (
|
||||
<Wifi className="w-6 h-6 text-red-400" />
|
||||
) : (
|
||||
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
|
||||
|
||||
{/* Details for debugging (collapsed by default) */}
|
||||
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
|
||||
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div>Type: {error.type}</div>
|
||||
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
|
||||
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
|
||||
{error.context.retryCount !== undefined && (
|
||||
<div>Retries: {error.context.retryCount}</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{error.isRetryable() && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isRetrying ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Go Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGoHome}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
|
||||
If this persists, please contact support at{' '}
|
||||
<a
|
||||
href="mailto:support@gridpilot.com"
|
||||
className="text-primary-blue hover:underline"
|
||||
>
|
||||
support@gridpilot.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen error display with more context
|
||||
*/
|
||||
export function FullScreenError({ error, onRetry }: ErrorDisplayProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-4">
|
||||
<ErrorDisplay error={error} onRetry={onRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
apps/website/components/errors/NotificationIntegration.tsx
Normal file
143
apps/website/components/errors/NotificationIntegration.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
|
||||
/**
|
||||
* Integration component that listens for API errors and shows notifications
|
||||
* Should be placed at the root level of the app
|
||||
*/
|
||||
export function NotificationIntegration() {
|
||||
const { addNotification } = useNotifications();
|
||||
const [lastConnectionStatus, setLastConnectionStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for custom notification events from error reporter
|
||||
const handleNotificationEvent = (event: CustomEvent) => {
|
||||
const { type, title, message, variant, autoDismiss } = event.detail;
|
||||
|
||||
addNotification({
|
||||
type: type || 'error',
|
||||
title: title || 'Error',
|
||||
message,
|
||||
variant: variant || 'toast',
|
||||
requiresResponse: variant === 'modal',
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for connection status changes
|
||||
const handleConnectionChange = () => {
|
||||
const status = connectionMonitor.getStatus();
|
||||
|
||||
if (status === 'disconnected' && lastConnectionStatus !== 'disconnected') {
|
||||
addNotification({
|
||||
type: 'connection_lost',
|
||||
title: 'Connection Lost',
|
||||
message: 'Unable to connect to the server. Please check your internet connection.',
|
||||
variant: 'modal',
|
||||
requiresResponse: true,
|
||||
});
|
||||
} else if (status === 'degraded' && lastConnectionStatus !== 'degraded') {
|
||||
addNotification({
|
||||
type: 'connection_degraded',
|
||||
title: 'Connection Issues',
|
||||
message: 'API connection is experiencing issues. Some features may be limited.',
|
||||
variant: 'toast',
|
||||
requiresResponse: false,
|
||||
});
|
||||
} else if (status === 'connected' && lastConnectionStatus === 'disconnected') {
|
||||
addNotification({
|
||||
type: 'connection_restored',
|
||||
title: 'Connection Restored',
|
||||
message: 'API connection has been restored.',
|
||||
variant: 'toast',
|
||||
requiresResponse: false,
|
||||
});
|
||||
}
|
||||
|
||||
setLastConnectionStatus(status);
|
||||
};
|
||||
|
||||
// Listen for gridpilot notification events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('gridpilot-notification', handleNotificationEvent as EventListener);
|
||||
}
|
||||
|
||||
// Monitor connection status changes
|
||||
connectionMonitor.on('disconnected', handleConnectionChange);
|
||||
connectionMonitor.on('degraded', handleConnectionChange);
|
||||
connectionMonitor.on('connected', handleConnectionChange);
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('gridpilot-notification', handleNotificationEvent as EventListener);
|
||||
}
|
||||
connectionMonitor.off('disconnected', handleConnectionChange);
|
||||
connectionMonitor.off('degraded', handleConnectionChange);
|
||||
connectionMonitor.off('connected', handleConnectionChange);
|
||||
};
|
||||
}, [addNotification, lastConnectionStatus]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manually trigger API error notifications
|
||||
*/
|
||||
export function useApiErrorNotifications() {
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
const showApiError = (error: ApiError) => {
|
||||
const isConnectivity = error.isConnectivityIssue();
|
||||
|
||||
addNotification({
|
||||
type: error.type.toLowerCase(),
|
||||
title: error.type.replace('_', ' ').toUpperCase(),
|
||||
message: error.getUserMessage(),
|
||||
variant: isConnectivity ? 'modal' : 'toast',
|
||||
requiresResponse: isConnectivity,
|
||||
actionUrl: isConnectivity ? undefined : '/support',
|
||||
});
|
||||
};
|
||||
|
||||
const showConnectionStatus = () => {
|
||||
const status = connectionMonitor.getStatus();
|
||||
const health = connectionMonitor.getHealth();
|
||||
|
||||
if (status === 'disconnected') {
|
||||
addNotification({
|
||||
type: 'connection_lost',
|
||||
title: 'API Unavailable',
|
||||
message: 'The API server is not responding. Please try again later.',
|
||||
variant: 'modal',
|
||||
requiresResponse: true,
|
||||
});
|
||||
} else if (status === 'degraded') {
|
||||
const reliability = ((health.successfulRequests / Math.max(health.totalRequests, 1)) * 100).toFixed(1);
|
||||
addNotification({
|
||||
type: 'connection_degraded',
|
||||
title: 'Degraded Performance',
|
||||
message: `API reliability is at ${reliability}%. Some features may not work correctly.`,
|
||||
variant: 'toast',
|
||||
requiresResponse: false,
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'connection_ok',
|
||||
title: 'Connection OK',
|
||||
message: 'API connection is healthy.',
|
||||
variant: 'toast',
|
||||
requiresResponse: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showApiError,
|
||||
showConnectionStatus,
|
||||
getHealth: () => connectionMonitor.getHealth(),
|
||||
getStatus: () => connectionMonitor.getStatus(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user