Files
agenciapsilmno/src/components/AppOfflineOverlay.vue
T

295 lines
8.4 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: 0.2s" />
<span class="offline-pulse-dot" style="animation-delay: 0.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, 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.1;
}
}
/* ── 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, 0.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>