Files
agenciapsilmno/src/components/AppOfflineOverlay.vue

260 lines
7.9 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/AppOfflineOverlay.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const isOnline = ref(true) // começa como true; detecta em onMounted
const wasOffline = ref(false)
const showReconnected = ref(false)
let pollTimer = null
let reconnectedTimer = null
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
async function checkConnectivity () {
try {
// favicon do próprio app (cache busted) — não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
})
setOnline()
} catch {
setOffline()
}
}
function setOnline () {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true
if (reconnectedTimer) clearTimeout(reconnectedTimer)
reconnectedTimer = setTimeout(() => { showReconnected.value = false }, 4000)
}
isOnline.value = true
}
function setOffline () {
if (isOnline.value) wasOffline.value = true
showReconnected.value = false
if (reconnectedTimer) clearTimeout(reconnectedTimer)
isOnline.value = false
}
// ── Eventos nativos do browser ────────────────────────────────
function onBrowserOffline () { setOffline() }
function onBrowserOnline () { checkConnectivity() } // confirma antes de marcar online
onMounted(() => {
window.addEventListener('offline', onBrowserOffline)
window.addEventListener('online', onBrowserOnline)
// Polling a cada 10 s — captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000)
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity()
})
onBeforeUnmount(() => {
window.removeEventListener('offline', onBrowserOffline)
window.removeEventListener('online', onBrowserOnline)
clearInterval(pollTimer)
clearTimeout(reconnectedTimer)
})
</script>
<template>
<Teleport to="body">
<!-- Overlay: sem internet -->
<Transition name="offline-fade">
<div
v-if="!isOnline"
class="offline-overlay"
role="alertdialog"
aria-live="assertive"
aria-label="Sem conexão com a internet"
>
<div class="offline-backdrop" />
<div class="offline-card">
<div class="offline-icon-wrap">
<span class="offline-icon-ring" />
<i class="pi pi-wifi offline-icon" />
</div>
<h2 class="offline-title">Sem conexão</h2>
<p class="offline-desc">
Verifique sua internet.<br>Tentando reconectar automaticamente
</p>
<div class="offline-pulse-bar">
<span class="offline-pulse-dot" />
<span class="offline-pulse-dot" style="animation-delay:.2s" />
<span class="offline-pulse-dot" style="animation-delay:.4s" />
</div>
</div>
</div>
</Transition>
<!-- Toast: reconectou -->
<Transition name="reconnect-toast">
<div v-if="showReconnected" class="reconnect-toast" role="status">
<i class="pi pi-check-circle" />
<span>Conexão restabelecida</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────────────────── */
.offline-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.offline-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ── Card ────────────────────────────────────────────────── */
.offline-card {
position: relative;
z-index: 1;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e0e0e0);
border-radius: 20px;
padding: 40px 48px;
max-width: 380px;
width: 100%;
text-align: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, .25);
}
/* ── Ícone ───────────────────────────────────────────────── */
.offline-icon-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 24px;
}
.offline-icon-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--primary-color, #6366f1);
opacity: 0.25;
animation: ring-pulse 2s ease-in-out infinite;
}
.offline-icon {
font-size: 2.2rem;
color: var(--primary-color, #6366f1);
opacity: 0.85;
position: relative;
}
@keyframes ring-pulse {
0%, 100% { transform: scale(1); opacity: 0.25; }
50% { transform: scale(1.18); opacity: 0.10; }
}
/* ── Texto ───────────────────────────────────────────────── */
.offline-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-color, #1a1a2e);
margin: 0 0 10px;
}
.offline-desc {
font-size: 0.88rem;
color: var(--text-color-secondary, #666);
margin: 0 0 28px;
line-height: 1.6;
}
/* ── Dots de pulso ───────────────────────────────────────── */
.offline-pulse-bar {
display: flex;
justify-content: center;
gap: 7px;
}
.offline-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
opacity: 0.7;
animation: dot-bounce 1.2s ease-in-out infinite;
}
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ── Transição do overlay ────────────────────────────────── */
.offline-fade-enter-active { transition: opacity 0.3s ease; }
.offline-fade-leave-active { transition: opacity 0.4s ease; }
.offline-fade-enter-from,
.offline-fade-leave-to { opacity: 0; }
/* ── Toast de reconexão ──────────────────────────────────── */
.reconnect-toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
background: #16a34a;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
padding: 10px 20px;
border-radius: 999px;
box-shadow: 0 4px 20px rgba(22, 163, 74, .4);
white-space: nowrap;
}
.reconnect-toast .pi { font-size: 1rem; }
.reconnect-toast-enter-active { transition: opacity 0.3s ease, transform 0.3s ease; }
.reconnect-toast-leave-active { transition: opacity 0.4s ease, transform 0.4s ease; }
.reconnect-toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(12px); }
.reconnect-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(12px); }
</style>