Files
TREK/client/src/components/Layout/OfflineBanner.tsx
T
Maurice 757764d046 hotfix: offline banner as bottom pill instead of full-width top bar
The top bar still blocked the trip planner's top nav on mobile even
after #808's padding trick — nav layouts that position their own
sticky headers were ignoring the --offline-banner-h offset, and the
bar looked alarming for what is usually a 2s blip.

Redesign as a small floating pill anchored bottom-center, hovering
above the mobile bottom nav (calc(var(--bottom-nav-h) + 16px)). No
layout shift anywhere, nothing ever covers the nav, and the pill
looks like a passing status chip rather than an error banner.

Reverts the body padding-top / navbar top offset introduced in #808
since they're no longer needed with the pill positioning.
2026-04-21 22:30:50 +02:00

93 lines
2.7 KiB
TypeScript

/**
* OfflineBanner — connectivity + sync state indicator.
*
* States:
* offline + N queued → amber pill "Offline · N queued"
* offline + 0 queued → amber pill "Offline"
* online + N pending → blue pill "Syncing N…"
* online + 0 pending → hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
useEffect(() => {
let cancelled = false
async function poll() {
const n = await mutationQueue.pendingCount()
if (!cancelled) setPendingCount(n)
}
poll()
const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [])
const hidden = isOnline && pendingCount === 0
if (hidden) return null
const offline = !isOnline
const bg = offline ? '#92400e' : '#1e40af'
const text = '#fff'
const label = offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
: 'Offline'
: `Syncing ${pendingCount}`
return (
<div
role="status"
aria-live="polite"
style={{
position: 'fixed',
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
// so the pill sits 16px from the bottom.
bottom: 'calc(var(--bottom-nav-h) + 16px)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
background: bg,
color: text,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 12,
fontWeight: 600,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
)
}