feat(swift): Epic 5 Progress - Error Handling & Keyboard Shortcuts (E5.S2-S3)
Epic 5 deels compleet: Error handling en keyboard shortcuts geïmplementeerd. Voortgang: 62 SP / 72 SP (86%) - Nog 1 story voor MVP compleet! E5.S2 - Error Handling (2 SP) - Gecentraliseerde error handler utility (lib/swift/error-handler.ts) - isOffline(), isNetworkError(), getErrorInfo() functies - safeFetch() wrapper met 30s timeout en retry logic - retryFetch() met exponential backoff (max 3 retries) - User-friendly Nederlandse error messages voor alle HTTP status codes - OfflineBanner component met online/offline detection - Alle blocks en CommandInput gebruiken nieuwe error handler - Context bar margin adjustment voor offline banner - parseErrorResponse() voor HTML error detection E5.S3 - Keyboard Shortcuts (2 SP) - ⌘Enter / Ctrl+Enter quick submit in CommandInput - ⌘Enter / Ctrl+Enter quick save in DagnotatieBlock - Visual hints (⌘↵) op submit button in DagnotatieBlock - Geverifieerd: ⌘K focus, Escape close, 1-3 FallbackPicker - Cross-platform support (macOS + Windows/Linux) - preventDefault() voor browser conflict preventie Components Updated: - DagnotatieBlock: safeFetch, getErrorInfo, retryFetch, ⌘Enter save - OverdrachtBlock: safeFetch, retryFetch voor AI generation - ZoekenBlock: safeFetch, getErrorInfo voor patient search - CommandInput: safeFetch, getErrorInfo, ⌘Enter submit - CommandCenter: OfflineBanner integration - ContextBar: useOffline hook voor margin adjustment Nieuwe Files: - lib/swift/error-handler.ts (301 regels) - Error handling utilities - components/swift/command-center/offline-banner.tsx (72 regels) - docs/swift/keyboard-shortcuts-reference.md (200+ regels) - docs/swift/test-plan-e5-s2-error-handling.md (350+ regels) - docs/swift/test-plan-e5-s3-keyboard-shortcuts.md (350+ regels) - docs/swift/PROJECT-STATUS-2024-12-27.md (500+ regels) - Status report Documentatie: - Bouwplan bijgewerkt naar v2.5 - Epic completion details toegevoegd met progress bars - Manual test checklist bijgewerkt (17/20 scenarios) - Risico's sectie bijgewerkt (alle major risks gemitigeerd) - Sprint planning status: E5 75% compleet (6/8 SP) Technische verbeteringen: - Offline detection met visual feedback - Network error retry met exponential backoff - HTTP 401/404/500/503 error messages in Nederlands - Timeout protection (30s) op alle API calls - Keyboard shortcuts met visual hints - Cross-platform shortcut support Testing: - Error handling test plan met 8 categorieën - Keyboard shortcuts test plan met 6 categorieën - 40+ test scenarios gedocumenteerd - Quick smoke test checklists Voortgang: 62 SP / 72 SP (86%) - E5.S2 en E5.S3 compleet 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, Search, User } from 'lucide-react';
|
||||
import { Loader2, Search, User, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/swift/error-handler';
|
||||
|
||||
interface DagnotitieBlockProps {
|
||||
prefill?: BlockPrefillData;
|
||||
@@ -82,31 +83,39 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(`/api/fhir/Patient?q=${encodeURIComponent(query)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const mappedPatients: Patient[] =
|
||||
data.entry?.map((e: { resource: any }) => {
|
||||
const p = e.resource;
|
||||
return {
|
||||
id: p.id,
|
||||
name_family: p.name?.[0]?.family,
|
||||
name_given: p.name?.[0]?.given || [],
|
||||
identifier_bsn: p.identifier?.find(
|
||||
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||
)?.value,
|
||||
};
|
||||
}) || [];
|
||||
setPatients(mappedPatients);
|
||||
setShowPatientDropdown(mappedPatients.length > 0);
|
||||
}
|
||||
const response = await safeFetch(
|
||||
`/api/fhir/Patient?q=${encodeURIComponent(query)}`,
|
||||
undefined,
|
||||
{ operation: 'Patiënt zoeken' }
|
||||
);
|
||||
const data = await response.json();
|
||||
const mappedPatients: Patient[] =
|
||||
data.entry?.map((e: { resource: any }) => {
|
||||
const p = e.resource;
|
||||
return {
|
||||
id: p.id,
|
||||
name_family: p.name?.[0]?.family,
|
||||
name_given: p.name?.[0]?.given || [],
|
||||
identifier_bsn: p.identifier?.find(
|
||||
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||
)?.value,
|
||||
};
|
||||
}) || [];
|
||||
setPatients(mappedPatients);
|
||||
setShowPatientDropdown(mappedPatients.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to search patients:', error);
|
||||
const errorInfo = getErrorInfo(error, { operation: 'Patiënt zoeken' });
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
setPatients([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
}, [toast]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
@@ -184,24 +193,28 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/reports', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
patient_id: patientId,
|
||||
type: 'verpleegkundig',
|
||||
content: content.trim(),
|
||||
category,
|
||||
include_in_handover: includeInHandover,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Onbekende fout' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
const response = await retryFetch(
|
||||
() =>
|
||||
safeFetch(
|
||||
'/api/reports',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
patient_id: patientId,
|
||||
type: 'verpleegkundig',
|
||||
content: content.trim(),
|
||||
category,
|
||||
include_in_handover: includeInHandover,
|
||||
}),
|
||||
},
|
||||
{ operation: 'Dagnotitie opslaan' }
|
||||
),
|
||||
3,
|
||||
1000
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -216,16 +229,36 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Failed to save dagnotitie:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Dagnotitie opslaan',
|
||||
statusCode,
|
||||
});
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Opslaan mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+Enter to save
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+Enter: save dagnotitie
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleSave]);
|
||||
|
||||
const formatPatientName = (patient: Patient): string => {
|
||||
const given = patient.name_given?.join(' ') || '';
|
||||
const family = patient.name_family || '';
|
||||
@@ -366,14 +399,21 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !patientId || !content.trim()}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !patientId || !content.trim()}
|
||||
title="Opslaan (⌘Enter)"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Opslaan...
|
||||
</>
|
||||
) : (
|
||||
'Opslaan'
|
||||
<>
|
||||
Opslaan
|
||||
<span className="ml-2 text-xs opacity-70 hidden sm:inline">⌘↵</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale/nl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/swift/error-handler';
|
||||
|
||||
interface OverdrachtBlockProps {
|
||||
prefill?: BlockPrefillData;
|
||||
@@ -64,10 +65,11 @@ export function OverdrachtBlock({ prefill }: OverdrachtBlockProps) {
|
||||
const fetchPatients = async () => {
|
||||
setIsLoadingPatients(true);
|
||||
try {
|
||||
const response = await fetch('/api/overdracht/patients');
|
||||
if (!response.ok) {
|
||||
throw new Error('Kon patiëntenlijst niet laden');
|
||||
}
|
||||
const response = await safeFetch(
|
||||
'/api/overdracht/patients',
|
||||
undefined,
|
||||
{ operation: 'Patiëntenlijst laden' }
|
||||
);
|
||||
const data = await response.json();
|
||||
setPatients(data.patients || []);
|
||||
|
||||
@@ -84,10 +86,15 @@ export function OverdrachtBlock({ prefill }: OverdrachtBlockProps) {
|
||||
setPatientSummaries(summaries);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch patients:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Patiëntenlijst laden',
|
||||
statusCode,
|
||||
});
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Laden mislukt',
|
||||
description: error instanceof Error ? error.message : 'Kon patiëntenlijst niet laden',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingPatients(false);
|
||||
@@ -125,16 +132,20 @@ export function OverdrachtBlock({ prefill }: OverdrachtBlockProps) {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/overdracht/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patientId, period }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Genereren mislukt' }));
|
||||
throw new Error(errorData.error || 'Genereren mislukt');
|
||||
}
|
||||
const response = await retryFetch(
|
||||
() =>
|
||||
safeFetch(
|
||||
'/api/overdracht/generate',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patientId, period }),
|
||||
},
|
||||
{ operation: 'Overdracht genereren' }
|
||||
),
|
||||
3,
|
||||
1000
|
||||
);
|
||||
|
||||
const summary: AISamenvatting = await response.json();
|
||||
|
||||
@@ -153,6 +164,11 @@ export function OverdrachtBlock({ prefill }: OverdrachtBlockProps) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate summary:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Overdracht genereren',
|
||||
statusCode,
|
||||
});
|
||||
setPatientSummaries((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const existing = updated.get(patientId);
|
||||
@@ -160,7 +176,7 @@ export function OverdrachtBlock({ prefill }: OverdrachtBlockProps) {
|
||||
updated.set(patientId, {
|
||||
...existing,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Onbekende fout',
|
||||
error: errorInfo.description,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, User, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { safeFetch, getErrorInfo } from '@/lib/swift/error-handler';
|
||||
|
||||
interface ZoekenBlockProps {
|
||||
prefill?: BlockPrefillData;
|
||||
@@ -53,20 +54,24 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(`/api/patients/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPatients(data.patients || []);
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Zoeken mislukt' }));
|
||||
throw new Error(errorData.error || 'Zoeken mislukt');
|
||||
}
|
||||
const response = await safeFetch(
|
||||
`/api/patients/search?q=${encodeURIComponent(query)}&limit=10`,
|
||||
undefined,
|
||||
{ operation: 'Patiënt zoeken' }
|
||||
);
|
||||
const data = await response.json();
|
||||
setPatients(data.patients || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to search patients:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Patiënt zoeken',
|
||||
statusCode,
|
||||
});
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Zoeken mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
setPatients([]);
|
||||
} finally {
|
||||
@@ -128,10 +133,11 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||
|
||||
try {
|
||||
// Fetch full patient data from FHIR API
|
||||
const response = await fetch(`/api/fhir/Patient/${patient.id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Patiënt data ophalen mislukt');
|
||||
}
|
||||
const response = await safeFetch(
|
||||
`/api/fhir/Patient/${patient.id}`,
|
||||
undefined,
|
||||
{ operation: 'Patiënt data ophalen' }
|
||||
);
|
||||
|
||||
const fhirPatient = await response.json();
|
||||
|
||||
@@ -205,10 +211,15 @@ export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to select patient:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Patiënt selecteren',
|
||||
statusCode,
|
||||
});
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Selectie mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
setSelectedPatientId(null);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ContextBar } from './context-bar';
|
||||
import { CommandInput } from './command-input';
|
||||
import { RecentStrip } from './recent-strip';
|
||||
import { CanvasArea } from './canvas-area';
|
||||
import { OfflineBanner } from './offline-banner';
|
||||
|
||||
export function CommandCenter() {
|
||||
const { closeBlock, activeBlock } = useSwiftStore();
|
||||
@@ -49,6 +50,9 @@ export function CommandCenter() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Offline Banner */}
|
||||
<OfflineBanner />
|
||||
|
||||
{/* Context Bar - 48px */}
|
||||
<ContextBar />
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import { useSwiftStore } from '@/stores/swift-store';
|
||||
import { useSwiftVoice } from '@/lib/swift/use-swift-voice';
|
||||
import type { BlockType } from '@/lib/swift/types';
|
||||
import { Mic, MicOff, Send, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { safeFetch, getErrorInfo } from '@/lib/swift/error-handler';
|
||||
|
||||
export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_, ref) {
|
||||
const {
|
||||
@@ -31,6 +33,7 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
|
||||
openBlock,
|
||||
addRecentAction,
|
||||
} = useSwiftStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
@@ -109,8 +112,8 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
|
||||
return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!hasValue || isProcessing) return;
|
||||
|
||||
// Stop recording if active
|
||||
@@ -123,16 +126,15 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
|
||||
|
||||
try {
|
||||
// Call intent classification API
|
||||
const response = await fetch('/api/intent/classify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: inputText }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Onbekende fout' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
const response = await safeFetch(
|
||||
'/api/intent/classify',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: inputText }),
|
||||
},
|
||||
{ operation: 'Intent classificeren' }
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
const { intent, confidence, entities } = result;
|
||||
@@ -158,6 +160,18 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing intent:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Intent classificeren',
|
||||
statusCode,
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
|
||||
// On error, show FallbackPicker so user can choose
|
||||
// This ensures the user's input is not lost
|
||||
@@ -176,6 +190,20 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+Enter to submit
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+Enter: submit command
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleSubmit]);
|
||||
|
||||
const isDisabled = isProcessing || activeBlock !== null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useSwiftStore, type ShiftType } from '@/stores/swift-store';
|
||||
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useOffline } from './offline-banner';
|
||||
|
||||
const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color: string }> = {
|
||||
nacht: { icon: Moon, label: 'Nachtdienst', color: 'text-indigo-600' },
|
||||
@@ -20,11 +21,15 @@ const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color:
|
||||
|
||||
export function ContextBar() {
|
||||
const { shift, activePatient, setActivePatient } = useSwiftStore();
|
||||
const isOffline = useOffline();
|
||||
const shiftConfig = SHIFT_CONFIG[shift];
|
||||
const ShiftIcon = shiftConfig.icon;
|
||||
|
||||
return (
|
||||
<header className="h-12 border-b border-slate-200 flex items-center px-4 justify-between shrink-0 bg-white">
|
||||
<header
|
||||
className="h-12 border-b border-slate-200 flex items-center px-4 justify-between shrink-0 bg-white"
|
||||
style={isOffline ? { marginTop: '40px' } : undefined}
|
||||
>
|
||||
{/* Left: Logo + Shift */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
|
||||
71
components/swift/command-center/offline-banner.tsx
Normal file
71
components/swift/command-center/offline-banner.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Offline Banner
|
||||
*
|
||||
* Toont een banner wanneer de gebruiker offline is.
|
||||
* E5.S2: Offline detection en melding.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WifiOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function OfflineBanner() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial check
|
||||
setIsOffline(!navigator.onLine);
|
||||
|
||||
// Listen for online/offline events
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 z-[100] bg-amber-500 text-white px-4 py-2',
|
||||
'flex items-center justify-center gap-2 text-sm font-medium',
|
||||
'shadow-md'
|
||||
)}
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
<WifiOff className="h-4 w-4" />
|
||||
<span>Geen internetverbinding</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook om te checken of de gebruiker offline is
|
||||
*/
|
||||
export function useOffline() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOffline(!navigator.onLine);
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOffline;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user