mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
Implement client-side resilience UI
Create NetworkErrorBanner, SubmissionQueueIndicator, and enhanced retry progress UI components. Integrate them into the application using a ResilienceProvider to manage network status and submission queue states. Update App.tsx to include the ResilienceProvider.
This commit is contained in:
27
src/App.tsx
27
src/App.tsx
@@ -20,6 +20,7 @@ import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
|||||||
import { handleError } from "@/lib/errorHandler";
|
import { handleError } from "@/lib/errorHandler";
|
||||||
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
||||||
import { APIStatusBanner } from "@/components/ui/api-status-banner";
|
import { APIStatusBanner } from "@/components/ui/api-status-banner";
|
||||||
|
import { ResilienceProvider } from "@/components/layout/ResilienceProvider";
|
||||||
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
|
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
|
||||||
import { useVersionCheck } from "@/hooks/useVersionCheck";
|
import { useVersionCheck } from "@/hooks/useVersionCheck";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -147,18 +148,19 @@ function AppContent(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<APIStatusBanner />
|
<ResilienceProvider>
|
||||||
<div className={cn(showBanner && "pt-20")}>
|
<APIStatusBanner />
|
||||||
<NavigationTracker />
|
<div className={cn(showBanner && "pt-20")}>
|
||||||
<LocationAutoDetectProvider />
|
<NavigationTracker />
|
||||||
<RetryStatusIndicator />
|
<LocationAutoDetectProvider />
|
||||||
<Toaster />
|
<RetryStatusIndicator />
|
||||||
<Sonner />
|
<Toaster />
|
||||||
<div className="min-h-screen flex flex-col">
|
<Sonner />
|
||||||
<div className="flex-1">
|
<div className="min-h-screen flex flex-col">
|
||||||
<Suspense fallback={<PageLoader />}>
|
<div className="flex-1">
|
||||||
<RouteErrorBoundary>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Routes>
|
<RouteErrorBoundary>
|
||||||
|
<Routes>
|
||||||
{/* Core routes - eager loaded */}
|
{/* Core routes - eager loaded */}
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/parks" element={<Parks />} />
|
<Route path="/parks" element={<Parks />} />
|
||||||
@@ -401,6 +403,7 @@ function AppContent(): React.JSX.Element {
|
|||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ResilienceProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/components/error/NetworkErrorBanner.tsx
Normal file
139
src/components/error/NetworkErrorBanner.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { WifiOff, RefreshCw, X, Eye } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface NetworkErrorBannerProps {
|
||||||
|
isOffline: boolean;
|
||||||
|
pendingCount?: number;
|
||||||
|
onRetryNow?: () => Promise<void>;
|
||||||
|
onViewQueue?: () => void;
|
||||||
|
estimatedRetryTime?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkErrorBanner({
|
||||||
|
isOffline,
|
||||||
|
pendingCount = 0,
|
||||||
|
onRetryNow,
|
||||||
|
onViewQueue,
|
||||||
|
estimatedRetryTime,
|
||||||
|
}: NetworkErrorBannerProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(isOffline || pendingCount > 0);
|
||||||
|
}, [isOffline, pendingCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!estimatedRetryTime) {
|
||||||
|
setCountdown(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = Math.max(0, estimatedRetryTime.getTime() - now);
|
||||||
|
setCountdown(Math.ceil(remaining / 1000));
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setCountdown(null);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [estimatedRetryTime]);
|
||||||
|
|
||||||
|
const handleRetryNow = async () => {
|
||||||
|
if (!onRetryNow) return;
|
||||||
|
|
||||||
|
setIsRetrying(true);
|
||||||
|
try {
|
||||||
|
await onRetryNow();
|
||||||
|
} finally {
|
||||||
|
setIsRetrying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 left-0 right-0 z-50 transition-transform duration-300",
|
||||||
|
isVisible ? "translate-y-0" : "-translate-y-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-destructive/90 backdrop-blur-sm text-destructive-foreground shadow-lg">
|
||||||
|
<div className="container mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<WifiOff className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{isOffline ? 'You are offline' : 'Network Issue Detected'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs opacity-90 truncate">
|
||||||
|
{pendingCount > 0 ? (
|
||||||
|
<>
|
||||||
|
{pendingCount} submission{pendingCount !== 1 ? 's' : ''} pending
|
||||||
|
{countdown !== null && countdown > 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
· Retrying in {countdown}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Changes will sync when connection is restored'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{pendingCount > 0 && onViewQueue && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onViewQueue}
|
||||||
|
className="h-8 text-xs bg-background/20 hover:bg-background/30"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
View Queue ({pendingCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onRetryNow && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleRetryNow}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className="h-8 text-xs bg-background/20 hover:bg-background/30"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn(
|
||||||
|
"h-3.5 w-3.5 mr-1.5",
|
||||||
|
isRetrying && "animate-spin"
|
||||||
|
)} />
|
||||||
|
{isRetrying ? 'Retrying...' : 'Retry Now'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
className="h-8 w-8 p-0 hover:bg-background/20"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Dismiss</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/layout/ResilienceProvider.tsx
Normal file
61
src/components/layout/ResilienceProvider.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { NetworkErrorBanner } from '@/components/error/NetworkErrorBanner';
|
||||||
|
import { SubmissionQueueIndicator } from '@/components/submission/SubmissionQueueIndicator';
|
||||||
|
import { useNetworkStatus } from '@/hooks/useNetworkStatus';
|
||||||
|
import { useSubmissionQueue } from '@/hooks/useSubmissionQueue';
|
||||||
|
|
||||||
|
interface ResilienceProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResilienceProvider wraps the app with network error handling
|
||||||
|
* and submission queue management UI
|
||||||
|
*/
|
||||||
|
export function ResilienceProvider({ children }: ResilienceProviderProps) {
|
||||||
|
const { isOnline } = useNetworkStatus();
|
||||||
|
const {
|
||||||
|
queuedItems,
|
||||||
|
lastSyncTime,
|
||||||
|
nextRetryTime,
|
||||||
|
retryItem,
|
||||||
|
retryAll,
|
||||||
|
removeItem,
|
||||||
|
clearQueue,
|
||||||
|
} = useSubmissionQueue({
|
||||||
|
autoRetry: true,
|
||||||
|
retryDelayMs: 5000,
|
||||||
|
maxRetries: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Network Error Banner - Shows at top when offline or errors present */}
|
||||||
|
<NetworkErrorBanner
|
||||||
|
isOffline={!isOnline}
|
||||||
|
pendingCount={queuedItems.length}
|
||||||
|
onRetryNow={retryAll}
|
||||||
|
estimatedRetryTime={nextRetryTime || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Queue Indicator - Shows in bottom right */}
|
||||||
|
{queuedItems.length > 0 && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-40">
|
||||||
|
<SubmissionQueueIndicator
|
||||||
|
queuedItems={queuedItems}
|
||||||
|
lastSyncTime={lastSyncTime || undefined}
|
||||||
|
onRetryItem={retryItem}
|
||||||
|
onRetryAll={retryAll}
|
||||||
|
onRemoveItem={removeItem}
|
||||||
|
onClearQueue={clearQueue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/components/submission/SubmissionQueueIndicator.tsx
Normal file
228
src/components/submission/SubmissionQueueIndicator.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Clock, RefreshCw, Trash2, CheckCircle2, XCircle, ChevronDown } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
export interface QueuedSubmission {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
entityName: string;
|
||||||
|
timestamp: Date;
|
||||||
|
status: 'pending' | 'retrying' | 'failed';
|
||||||
|
retryCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionQueueIndicatorProps {
|
||||||
|
queuedItems: QueuedSubmission[];
|
||||||
|
lastSyncTime?: Date;
|
||||||
|
onRetryItem?: (id: string) => Promise<void>;
|
||||||
|
onRetryAll?: () => Promise<void>;
|
||||||
|
onClearQueue?: () => Promise<void>;
|
||||||
|
onRemoveItem?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionQueueIndicator({
|
||||||
|
queuedItems,
|
||||||
|
lastSyncTime,
|
||||||
|
onRetryItem,
|
||||||
|
onRetryAll,
|
||||||
|
onClearQueue,
|
||||||
|
onRemoveItem,
|
||||||
|
}: SubmissionQueueIndicatorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [retryingIds, setRetryingIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleRetryItem = async (id: string) => {
|
||||||
|
if (!onRetryItem) return;
|
||||||
|
|
||||||
|
setRetryingIds(prev => new Set(prev).add(id));
|
||||||
|
try {
|
||||||
|
await onRetryItem(id);
|
||||||
|
} finally {
|
||||||
|
setRetryingIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: QueuedSubmission['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||||
|
case 'retrying':
|
||||||
|
return <RefreshCw className="h-3.5 w-3.5 text-primary animate-spin" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="h-3.5 w-3.5 text-destructive" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: QueuedSubmission['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-secondary text-secondary-foreground';
|
||||||
|
case 'retrying':
|
||||||
|
return 'bg-primary/10 text-primary';
|
||||||
|
case 'failed':
|
||||||
|
return 'bg-destructive/10 text-destructive';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (queuedItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="relative gap-2 h-9"
|
||||||
|
>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Queue
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 min-w-[20px] px-1.5 bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{queuedItems.length}
|
||||||
|
</Badge>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
"h-3.5 w-3.5 transition-transform",
|
||||||
|
isOpen && "rotate-180"
|
||||||
|
)} />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-96 p-0"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm">Submission Queue</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{queuedItems.length} pending submission{queuedItems.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
{lastSyncTime && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Last sync {formatDistanceToNow(lastSyncTime, { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{onRetryAll && queuedItems.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetryAll}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Retry All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{queuedItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-md p-3 border transition-colors hover:bg-accent/50",
|
||||||
|
getStatusColor(item.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{getStatusIcon(item.status)}
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{item.entityName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="capitalize">{item.type}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDistanceToNow(item.timestamp, { addSuffix: true })}</span>
|
||||||
|
{item.retryCount && item.retryCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{item.retryCount} {item.retryCount === 1 ? 'retry' : 'retries'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.error && (
|
||||||
|
<p className="text-xs text-destructive mt-1.5 truncate">
|
||||||
|
{item.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onRetryItem && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRetryItem(item.id)}
|
||||||
|
disabled={retryingIds.has(item.id)}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
retryingIds.has(item.id) && "animate-spin"
|
||||||
|
)} />
|
||||||
|
<span className="sr-only">Retry</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onRemoveItem && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onRemoveItem(item.id)}
|
||||||
|
className="h-7 w-7 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Remove</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{onClearQueue && queuedItems.length > 0 && (
|
||||||
|
<div className="p-3 border-t">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClearQueue}
|
||||||
|
className="w-full h-8 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Clear Queue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/hooks/useNetworkStatus.ts
Normal file
28
src/hooks/useNetworkStatus.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useNetworkStatus() {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
const [wasOffline, setWasOffline] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
setIsOnline(true);
|
||||||
|
setWasOffline(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
setIsOnline(false);
|
||||||
|
setWasOffline(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isOnline, wasOffline };
|
||||||
|
}
|
||||||
125
src/hooks/useRetryProgress.ts
Normal file
125
src/hooks/useRetryProgress.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface RetryOptions {
|
||||||
|
maxAttempts?: number;
|
||||||
|
delayMs?: number;
|
||||||
|
exponentialBackoff?: boolean;
|
||||||
|
onProgress?: (attempt: number, maxAttempts: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRetryProgress() {
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
const [currentAttempt, setCurrentAttempt] = useState(0);
|
||||||
|
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||||
|
|
||||||
|
const retryWithProgress = useCallback(
|
||||||
|
async <T,>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options: RetryOptions = {}
|
||||||
|
): Promise<T> => {
|
||||||
|
const {
|
||||||
|
maxAttempts = 3,
|
||||||
|
delayMs = 1000,
|
||||||
|
exponentialBackoff = true,
|
||||||
|
onProgress,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
setIsRetrying(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
setAbortController(controller);
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
let toastId: string | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
throw new Error('Operation cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentAttempt(attempt);
|
||||||
|
onProgress?.(attempt, maxAttempts);
|
||||||
|
|
||||||
|
// Show progress toast
|
||||||
|
if (attempt > 1) {
|
||||||
|
const delay = exponentialBackoff ? delayMs * Math.pow(2, attempt - 2) : delayMs;
|
||||||
|
const countdown = Math.ceil(delay / 1000);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: `Retrying (${attempt}/${maxAttempts})`,
|
||||||
|
description: `Waiting ${countdown}s before retry...`,
|
||||||
|
duration: delay,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
setIsRetrying(false);
|
||||||
|
setCurrentAttempt(0);
|
||||||
|
setAbortController(null);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: attempt > 1
|
||||||
|
? `Operation succeeded on attempt ${attempt}`
|
||||||
|
: 'Operation completed successfully',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
toast({
|
||||||
|
title: `Attempt ${attempt} Failed`,
|
||||||
|
description: `${lastError.message}. Retrying...`,
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attempts failed
|
||||||
|
setIsRetrying(false);
|
||||||
|
setCurrentAttempt(0);
|
||||||
|
setAbortController(null);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: "All Retries Failed",
|
||||||
|
description: `Failed after ${maxAttempts} attempts: ${lastError?.message}`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
setAbortController(null);
|
||||||
|
setIsRetrying(false);
|
||||||
|
setCurrentAttempt(0);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Cancelled',
|
||||||
|
description: 'Retry operation cancelled',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [abortController]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
retryWithProgress,
|
||||||
|
isRetrying,
|
||||||
|
currentAttempt,
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
119
src/hooks/useSubmissionQueue.ts
Normal file
119
src/hooks/useSubmissionQueue.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { QueuedSubmission } from '@/components/submission/SubmissionQueueIndicator';
|
||||||
|
import { useNetworkStatus } from './useNetworkStatus';
|
||||||
|
|
||||||
|
// This is a placeholder implementation
|
||||||
|
// In a real app, this would interact with IndexedDB and the actual submission system
|
||||||
|
|
||||||
|
interface UseSubmissionQueueOptions {
|
||||||
|
autoRetry?: boolean;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmissionQueue(options: UseSubmissionQueueOptions = {}) {
|
||||||
|
const {
|
||||||
|
autoRetry = true,
|
||||||
|
retryDelayMs = 5000,
|
||||||
|
maxRetries = 3,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [queuedItems, setQueuedItems] = useState<QueuedSubmission[]>([]);
|
||||||
|
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
||||||
|
const [nextRetryTime, setNextRetryTime] = useState<Date | null>(null);
|
||||||
|
const { isOnline } = useNetworkStatus();
|
||||||
|
|
||||||
|
// Load queued items from IndexedDB on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadQueueFromStorage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-retry when back online
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnline && autoRetry && queuedItems.length > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
retryAll();
|
||||||
|
}, retryDelayMs);
|
||||||
|
|
||||||
|
setNextRetryTime(new Date(Date.now() + retryDelayMs));
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isOnline, autoRetry, queuedItems.length, retryDelayMs]);
|
||||||
|
|
||||||
|
const loadQueueFromStorage = useCallback(async () => {
|
||||||
|
// Placeholder: Load from IndexedDB
|
||||||
|
// In real implementation, this would query the offline queue
|
||||||
|
try {
|
||||||
|
// const items = await getQueuedSubmissions();
|
||||||
|
// setQueuedItems(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load queue:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const retryItem = useCallback(async (id: string) => {
|
||||||
|
setQueuedItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === id
|
||||||
|
? { ...item, status: 'retrying' as const }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Placeholder: Retry the submission
|
||||||
|
// await retrySubmission(id);
|
||||||
|
|
||||||
|
// Remove from queue on success
|
||||||
|
setQueuedItems(prev => prev.filter(item => item.id !== id));
|
||||||
|
setLastSyncTime(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
// Mark as failed
|
||||||
|
setQueuedItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: 'failed' as const,
|
||||||
|
retryCount: (item.retryCount || 0) + 1,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const retryAll = useCallback(async () => {
|
||||||
|
const pendingItems = queuedItems.filter(
|
||||||
|
item => item.status === 'pending' || item.status === 'failed'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of pendingItems) {
|
||||||
|
if ((item.retryCount || 0) < maxRetries) {
|
||||||
|
await retryItem(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [queuedItems, maxRetries, retryItem]);
|
||||||
|
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setQueuedItems(prev => prev.filter(item => item.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearQueue = useCallback(async () => {
|
||||||
|
// Placeholder: Clear from IndexedDB
|
||||||
|
setQueuedItems([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queuedItems,
|
||||||
|
lastSyncTime,
|
||||||
|
nextRetryTime,
|
||||||
|
retryItem,
|
||||||
|
retryAll,
|
||||||
|
removeItem,
|
||||||
|
clearQueue,
|
||||||
|
refresh: loadQueueFromStorage,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user