feat(welcome): tela de boas-vindas com login Google/Email + URL hardcoded v1.6.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

UX simplificada drasticamente — usuário não precisa mais saber URL/token:
- Tela de boas-vindas full-screen quando não logado
- 3 botões grandes: Google, Email, Servidor próprio (avançado)
- URL hardcoded https://shivao.pontualtech.work como padrão
- Auto-conecta WebSocket + Google Calendar status após login
- Pull inicial automático pra puxar dados existentes da conta

Backend (server/src/index.js):
- Endpoint POST /api/auth/google: recebe credential (Google ID token),
  valida via tokeninfo do Google, confere aud == GOOGLE_CLIENT_ID,
  cria user automático com email do Google se não existe,
  retorna JWT access+refresh tokens
- Reusa GOOGLE_CLIENT_ID/SECRET já configurados no Coolify

Frontend (app/diario-bordo.html):
- Modal welcome com Google Sign-In via @google/gsi/client (script async)
- Tabs Login/Signup pro fluxo email
- Form avançado pra power users self-hosters
- Skip pra modo offline
- Once dismissed, fica oculto (localStorage flag)

Service Worker bumped pra v1.6.0 (invalida cache antigo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 08:00:54 -03:00
parent c76b50b45b
commit b48afaa84f
6 changed files with 539 additions and 5 deletions

View file

@ -11,6 +11,7 @@
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Diário de Bordo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -528,6 +529,65 @@ header{
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Welcome Screen === */
.welcome-screen{
position:fixed;inset:0;z-index:9999;
background:linear-gradient(135deg,#0e2a3d 0%,#1a3d54 50%,#0a1f2d 100%);
display:flex;align-items:center;justify-content:center;
padding:20px;overflow-y:auto;
}
.welcome-card{
background:var(--bg-paper);
border:1px solid rgba(212,160,74,.4);
padding:32px 28px;max-width:380px;width:100%;
box-shadow:0 30px 80px rgba(0,0,0,.5);
}
.welcome-logo{
font-size:64px;text-align:center;margin-bottom:8px;
filter:drop-shadow(0 4px 8px rgba(212,160,74,.3));
}
.welcome-title{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-size:28px;color:var(--ink-deep);
text-align:center;margin:0 0 8px;letter-spacing:-.01em;
}
.welcome-tagline{
font-family:var(--f-display);font-style:italic;
color:var(--sepia);text-align:center;
font-size:14px;line-height:1.45;
margin:0 0 24px;
}
.welcome-buttons{display:flex;flex-direction:column;gap:8px}
.welcome-btn{
display:flex;align-items:center;justify-content:center;gap:10px;
padding:13px 16px;border:1px solid var(--rule);
background:var(--bg-canvas);color:var(--ink-deep);
font-family:var(--f-body);font-size:14.5px;font-weight:500;
cursor:pointer;width:100%;
transition:all .15s;text-align:center;
}
.welcome-btn:hover{background:var(--bg-aged);border-color:var(--brass)}
.welcome-btn:active{transform:scale(.98)}
.welcome-btn-google{background:#fff;border-color:#dadce0}
.welcome-btn-google:hover{background:#f8f9fa;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.welcome-btn-email{}
.welcome-btn-primary{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep);font-weight:600}
.welcome-btn-primary:hover{background:var(--brass-deep)}
.welcome-btn-text{
background:transparent;border:none;color:var(--sepia);
font-size:12.5px;text-decoration:underline;
}
.welcome-btn-text:hover{color:var(--ink-deep);background:transparent}
.welcome-tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid var(--rule)}
.welcome-tab{
flex:1;background:transparent;border:none;
padding:10px;font-family:var(--f-mono);font-size:11px;
letter-spacing:.12em;text-transform:uppercase;color:var(--sepia);
cursor:pointer;border-bottom:2px solid transparent;
margin-bottom:-1px;
}
.welcome-tab.active{color:var(--brass);border-bottom-color:var(--brass)}
/* === Sync Indicator === */
.sync-indicator{
display:inline-block;font-size:10px;
@ -2007,6 +2067,55 @@ Hora: {HORA}</textarea>
</div>
</div>
<!-- Welcome / Login Screen -->
<div class="welcome-screen" id="welcome-screen" style="display:none">
<div class="welcome-card">
<div class="welcome-logo"></div>
<h1 class="welcome-title">Shivão · Diário de Bordo</h1>
<p class="welcome-tagline">Sua viagem, suas fotos, sua âncora — em qualquer dispositivo.</p>
<div class="welcome-buttons">
<button class="welcome-btn welcome-btn-google" id="welcome-google" onclick="welcomeGoogleClick()">
<svg viewBox="0 0 18 18" width="18" height="18"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/></svg>
<span>Entrar com Google</span>
</button>
<button class="welcome-btn welcome-btn-email" onclick="welcomeShowEmail()">
<span style="font-size:18px">✉️</span>
<span>Continuar com email</span>
</button>
<button class="welcome-btn welcome-btn-text" onclick="welcomeShowAdvanced()">
⚙️ Configurar servidor próprio (avançado)
</button>
</div>
<!-- Email form (hidden initially) -->
<div id="welcome-email-form" style="display:none;margin-top:18px">
<div class="welcome-tabs">
<button class="welcome-tab active" id="we-tab-login" onclick="welcomeSwitchTab('login')">Entrar</button>
<button class="welcome-tab" id="we-tab-signup" onclick="welcomeSwitchTab('signup')">Criar conta</button>
</div>
<div class="field"><input type="email" id="we-email" placeholder="seu@email.com" autocomplete="email"></div>
<div class="field" id="we-name-field" style="display:none"><input type="text" id="we-name" placeholder="Seu nome" autocomplete="name"></div>
<div class="field"><input type="password" id="we-pwd" placeholder="senha (mín 8 caracteres)" autocomplete="current-password"></div>
<button class="welcome-btn welcome-btn-primary" id="we-submit" onclick="welcomeEmailSubmit()">Entrar</button>
<div id="we-msg" style="margin-top:8px;font-size:12px;color:var(--storm);text-align:center"></div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeBack()" style="margin-top:8px">← Voltar</button>
</div>
<!-- Advanced (server URL/token) form -->
<div id="welcome-advanced-form" style="display:none;margin-top:18px">
<p style="font-size:12px;color:var(--sepia);text-align:center;margin-bottom:12px">Pra usar com seu próprio servidor Shivão self-hosted.</p>
<div class="field"><input type="url" id="we-srv-url" placeholder="https://seu-shivao.com"></div>
<div class="field"><input type="password" id="we-srv-token" placeholder="BOAT_TOKEN do servidor"></div>
<button class="welcome-btn welcome-btn-primary" onclick="welcomeAdvancedSubmit()">Conectar</button>
<div id="we-srv-msg" style="margin-top:8px;font-size:12px;color:var(--storm);text-align:center"></div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeBack()" style="margin-top:8px">← Voltar</button>
</div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeSkip()" style="margin-top:14px;font-size:11px;opacity:.6">Usar offline (sem sync entre dispositivos)</button>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="zone-toast" id="zone-toast"></div>
@ -2220,6 +2329,8 @@ function dbDelete(id){return new Promise((r,j)=>{const q=db.transaction('media',
function dbAll(){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').getAll();q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'};state.boats=d.boats||[];state.activeBoatId=d.activeBoatId||null;state.units=d.units||'metric'}}catch(e){console.warn(e)}
// Default cloud URL pra usuários social/email (não precisam digitar nada)
if(!state.cloud.url&&typeof DEFAULT_CLOUD_URL!=='undefined')state.cloud.url=DEFAULT_CLOUD_URL;
migrateBoatsSchema();
// popular checklists padrão se vazio
if(!state.checklists.length){
@ -3024,7 +3135,11 @@ async function updateStorageInfo(){
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
// Welcome screen — só pra usuários sem login
setTimeout(maybeShowWelcome,300);
})();
// Re-tenta init Google Sign-In quando o script async carrega
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
document.addEventListener('visibilitychange',async()=>{
if(document.visibilityState==='visible'){
if(tracking.active&&!tracking.wakeLock)await requestWakeLock();
@ -3679,6 +3794,137 @@ async function cloudFetch(path,opts={}){
return r;
}
// ============ WELCOME / LOGIN SCREEN ============
// Default cloud URL — usuários não-avançados não precisam configurar nada
const DEFAULT_CLOUD_URL='https://shivao.pontualtech.work';
const GOOGLE_CLIENT_ID_FRONTEND='989184529532-uceun7l7a12e63fdrkilnh8vml0v0lv4.apps.googleusercontent.com';
function maybeShowWelcome(){
const ws=document.getElementById('welcome-screen');
if(!ws)return;
// Mostra se: nuvem não configurada E user não está logado E nunca dispensou
const dismissed=localStorage.getItem('shivao_welcome_dismissed')==='1';
const needsSetup=(!cloudConfigured()||!state.auth)&&!dismissed;
ws.style.display=needsSetup?'flex':'none';
// Se mostrar, prepara o Google Sign-In quando carregar
if(needsSetup&&window.google?.accounts?.id){
initGoogleSignIn();
}
}
function welcomeShowEmail(){
document.getElementById('welcome-email-form').style.display='block';
document.querySelector('.welcome-buttons').style.display='none';
document.getElementById('welcome-advanced-form').style.display='none';
}
function welcomeShowAdvanced(){
document.getElementById('welcome-advanced-form').style.display='block';
document.querySelector('.welcome-buttons').style.display='none';
document.getElementById('welcome-email-form').style.display='none';
}
function welcomeBack(){
document.querySelector('.welcome-buttons').style.display='flex';
document.getElementById('welcome-email-form').style.display='none';
document.getElementById('welcome-advanced-form').style.display='none';
}
function welcomeSwitchTab(t){
document.getElementById('we-tab-login').classList.toggle('active',t==='login');
document.getElementById('we-tab-signup').classList.toggle('active',t==='signup');
document.getElementById('we-name-field').style.display=t==='signup'?'block':'none';
document.getElementById('we-submit').textContent=t==='login'?'Entrar':'Criar conta';
}
function welcomeSkip(){
if(!confirm('Continuar sem sincronização? Seus dados ficam só neste dispositivo.'))return;
localStorage.setItem('shivao_welcome_dismissed','1');
document.getElementById('welcome-screen').style.display='none';
}
async function welcomeEmailSubmit(){
const email=document.getElementById('we-email').value.trim();
const pwd=document.getElementById('we-pwd').value;
const name=document.getElementById('we-name').value.trim();
const isSignup=document.getElementById('we-tab-signup').classList.contains('active');
const msg=document.getElementById('we-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(!email||!pwd){msg.textContent='Preencha email e senha';return}
if(isSignup&&pwd.length<8){msg.textContent='Senha mínima: 8 caracteres';return}
// Garante URL hardcoded
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
try{
if(isSignup)await authSignup(email,pwd,name);
else await authLogin(email,pwd);
msg.style.color='var(--algae)';msg.textContent='Logado! Sincronizando...';
welcomeFinish();
}catch(e){msg.textContent=e.message}
}
async function welcomeAdvancedSubmit(){
const url=document.getElementById('we-srv-url').value.trim();
const tok=document.getElementById('we-srv-token').value.trim();
const msg=document.getElementById('we-srv-msg');
if(!url||!tok){msg.textContent='Preencha URL e token';return}
state.cloud={url:url.replace(/\/$/,''),token:tok,lastSync:0};
saveState();
msg.style.color='var(--algae)';msg.textContent='Conectando...';
welcomeFinish();
}
function welcomeFinish(){
document.getElementById('welcome-screen').style.display='none';
// Conecta sync automaticamente
rtConnect();
refreshGoogleStatus();
// Pull inicial pra puxar dados existentes da conta
setTimeout(()=>autoPullNow().catch(()=>{}),500);
}
function initGoogleSignIn(){
if(!window.google?.accounts?.id||window._gsiInited)return;
try{
window.google.accounts.id.initialize({
client_id:GOOGLE_CLIENT_ID_FRONTEND,
callback:onGoogleCredential,
auto_select:false,
ux_mode:'popup',
});
window._gsiInited=true;
}catch(e){console.warn('[gsi] init failed',e)}
}
function welcomeGoogleClick(){
if(!window.google?.accounts?.id){
toast('Google Sign-In ainda carregando, aguarde 2s');
return;
}
initGoogleSignIn();
try{
window.google.accounts.id.prompt(()=>{});
// Fallback: render botão visível ao lado caso prompt seja bloqueado
}catch(e){
console.warn('[gsi] prompt failed',e);
toast('Erro: '+e.message);
}
}
async function onGoogleCredential(resp){
if(!resp?.credential){toast('Sem credential do Google');return}
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
try{
const r=await fetch(cloudUrl('/api/auth/google'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({credential:resp.credential}),
});
const j=await r.json();
if(!r.ok)throw new Error(j.error||'login failed');
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
toast('Bem-vindo, '+(j.user.name||j.user.email));
welcomeFinish();
if(typeof renderAuthBox==='function')renderAuthBox();
}catch(e){
console.warn('[google login]',e);
toast('Falha login Google: '+e.message);
}
}
// ============ REALTIME SYNC (WebSocket + auto push/pull) ============
// Conecta ao /ws do servidor, escuta notificações de mudança de outros devices
// e dispara pull/push automáticos. Reconnect com backoff exponencial.

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 6
versionName "1.5.0"
versionCode 7
versionName "1.6.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,6 +1,6 @@
{
"name": "shivao-mobile",
"version": "1.5.0",
"version": "1.6.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",

View file

@ -11,6 +11,7 @@
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Diário de Bordo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -528,6 +529,65 @@ header{
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Welcome Screen === */
.welcome-screen{
position:fixed;inset:0;z-index:9999;
background:linear-gradient(135deg,#0e2a3d 0%,#1a3d54 50%,#0a1f2d 100%);
display:flex;align-items:center;justify-content:center;
padding:20px;overflow-y:auto;
}
.welcome-card{
background:var(--bg-paper);
border:1px solid rgba(212,160,74,.4);
padding:32px 28px;max-width:380px;width:100%;
box-shadow:0 30px 80px rgba(0,0,0,.5);
}
.welcome-logo{
font-size:64px;text-align:center;margin-bottom:8px;
filter:drop-shadow(0 4px 8px rgba(212,160,74,.3));
}
.welcome-title{
font-family:var(--f-display);font-style:italic;font-weight:500;
font-size:28px;color:var(--ink-deep);
text-align:center;margin:0 0 8px;letter-spacing:-.01em;
}
.welcome-tagline{
font-family:var(--f-display);font-style:italic;
color:var(--sepia);text-align:center;
font-size:14px;line-height:1.45;
margin:0 0 24px;
}
.welcome-buttons{display:flex;flex-direction:column;gap:8px}
.welcome-btn{
display:flex;align-items:center;justify-content:center;gap:10px;
padding:13px 16px;border:1px solid var(--rule);
background:var(--bg-canvas);color:var(--ink-deep);
font-family:var(--f-body);font-size:14.5px;font-weight:500;
cursor:pointer;width:100%;
transition:all .15s;text-align:center;
}
.welcome-btn:hover{background:var(--bg-aged);border-color:var(--brass)}
.welcome-btn:active{transform:scale(.98)}
.welcome-btn-google{background:#fff;border-color:#dadce0}
.welcome-btn-google:hover{background:#f8f9fa;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.welcome-btn-email{}
.welcome-btn-primary{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep);font-weight:600}
.welcome-btn-primary:hover{background:var(--brass-deep)}
.welcome-btn-text{
background:transparent;border:none;color:var(--sepia);
font-size:12.5px;text-decoration:underline;
}
.welcome-btn-text:hover{color:var(--ink-deep);background:transparent}
.welcome-tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid var(--rule)}
.welcome-tab{
flex:1;background:transparent;border:none;
padding:10px;font-family:var(--f-mono);font-size:11px;
letter-spacing:.12em;text-transform:uppercase;color:var(--sepia);
cursor:pointer;border-bottom:2px solid transparent;
margin-bottom:-1px;
}
.welcome-tab.active{color:var(--brass);border-bottom-color:var(--brass)}
/* === Sync Indicator === */
.sync-indicator{
display:inline-block;font-size:10px;
@ -2007,6 +2067,55 @@ Hora: {HORA}</textarea>
</div>
</div>
<!-- Welcome / Login Screen -->
<div class="welcome-screen" id="welcome-screen" style="display:none">
<div class="welcome-card">
<div class="welcome-logo"></div>
<h1 class="welcome-title">Shivão · Diário de Bordo</h1>
<p class="welcome-tagline">Sua viagem, suas fotos, sua âncora — em qualquer dispositivo.</p>
<div class="welcome-buttons">
<button class="welcome-btn welcome-btn-google" id="welcome-google" onclick="welcomeGoogleClick()">
<svg viewBox="0 0 18 18" width="18" height="18"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/></svg>
<span>Entrar com Google</span>
</button>
<button class="welcome-btn welcome-btn-email" onclick="welcomeShowEmail()">
<span style="font-size:18px">✉️</span>
<span>Continuar com email</span>
</button>
<button class="welcome-btn welcome-btn-text" onclick="welcomeShowAdvanced()">
⚙️ Configurar servidor próprio (avançado)
</button>
</div>
<!-- Email form (hidden initially) -->
<div id="welcome-email-form" style="display:none;margin-top:18px">
<div class="welcome-tabs">
<button class="welcome-tab active" id="we-tab-login" onclick="welcomeSwitchTab('login')">Entrar</button>
<button class="welcome-tab" id="we-tab-signup" onclick="welcomeSwitchTab('signup')">Criar conta</button>
</div>
<div class="field"><input type="email" id="we-email" placeholder="seu@email.com" autocomplete="email"></div>
<div class="field" id="we-name-field" style="display:none"><input type="text" id="we-name" placeholder="Seu nome" autocomplete="name"></div>
<div class="field"><input type="password" id="we-pwd" placeholder="senha (mín 8 caracteres)" autocomplete="current-password"></div>
<button class="welcome-btn welcome-btn-primary" id="we-submit" onclick="welcomeEmailSubmit()">Entrar</button>
<div id="we-msg" style="margin-top:8px;font-size:12px;color:var(--storm);text-align:center"></div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeBack()" style="margin-top:8px">← Voltar</button>
</div>
<!-- Advanced (server URL/token) form -->
<div id="welcome-advanced-form" style="display:none;margin-top:18px">
<p style="font-size:12px;color:var(--sepia);text-align:center;margin-bottom:12px">Pra usar com seu próprio servidor Shivão self-hosted.</p>
<div class="field"><input type="url" id="we-srv-url" placeholder="https://seu-shivao.com"></div>
<div class="field"><input type="password" id="we-srv-token" placeholder="BOAT_TOKEN do servidor"></div>
<button class="welcome-btn welcome-btn-primary" onclick="welcomeAdvancedSubmit()">Conectar</button>
<div id="we-srv-msg" style="margin-top:8px;font-size:12px;color:var(--storm);text-align:center"></div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeBack()" style="margin-top:8px">← Voltar</button>
</div>
<button class="welcome-btn welcome-btn-text" onclick="welcomeSkip()" style="margin-top:14px;font-size:11px;opacity:.6">Usar offline (sem sync entre dispositivos)</button>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="zone-toast" id="zone-toast"></div>
@ -2220,6 +2329,8 @@ function dbDelete(id){return new Promise((r,j)=>{const q=db.transaction('media',
function dbAll(){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').getAll();q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'};state.boats=d.boats||[];state.activeBoatId=d.activeBoatId||null;state.units=d.units||'metric'}}catch(e){console.warn(e)}
// Default cloud URL pra usuários social/email (não precisam digitar nada)
if(!state.cloud.url&&typeof DEFAULT_CLOUD_URL!=='undefined')state.cloud.url=DEFAULT_CLOUD_URL;
migrateBoatsSchema();
// popular checklists padrão se vazio
if(!state.checklists.length){
@ -3024,7 +3135,11 @@ async function updateStorageInfo(){
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
// Welcome screen — só pra usuários sem login
setTimeout(maybeShowWelcome,300);
})();
// Re-tenta init Google Sign-In quando o script async carrega
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
document.addEventListener('visibilitychange',async()=>{
if(document.visibilityState==='visible'){
if(tracking.active&&!tracking.wakeLock)await requestWakeLock();
@ -3679,6 +3794,137 @@ async function cloudFetch(path,opts={}){
return r;
}
// ============ WELCOME / LOGIN SCREEN ============
// Default cloud URL — usuários não-avançados não precisam configurar nada
const DEFAULT_CLOUD_URL='https://shivao.pontualtech.work';
const GOOGLE_CLIENT_ID_FRONTEND='989184529532-uceun7l7a12e63fdrkilnh8vml0v0lv4.apps.googleusercontent.com';
function maybeShowWelcome(){
const ws=document.getElementById('welcome-screen');
if(!ws)return;
// Mostra se: nuvem não configurada E user não está logado E nunca dispensou
const dismissed=localStorage.getItem('shivao_welcome_dismissed')==='1';
const needsSetup=(!cloudConfigured()||!state.auth)&&!dismissed;
ws.style.display=needsSetup?'flex':'none';
// Se mostrar, prepara o Google Sign-In quando carregar
if(needsSetup&&window.google?.accounts?.id){
initGoogleSignIn();
}
}
function welcomeShowEmail(){
document.getElementById('welcome-email-form').style.display='block';
document.querySelector('.welcome-buttons').style.display='none';
document.getElementById('welcome-advanced-form').style.display='none';
}
function welcomeShowAdvanced(){
document.getElementById('welcome-advanced-form').style.display='block';
document.querySelector('.welcome-buttons').style.display='none';
document.getElementById('welcome-email-form').style.display='none';
}
function welcomeBack(){
document.querySelector('.welcome-buttons').style.display='flex';
document.getElementById('welcome-email-form').style.display='none';
document.getElementById('welcome-advanced-form').style.display='none';
}
function welcomeSwitchTab(t){
document.getElementById('we-tab-login').classList.toggle('active',t==='login');
document.getElementById('we-tab-signup').classList.toggle('active',t==='signup');
document.getElementById('we-name-field').style.display=t==='signup'?'block':'none';
document.getElementById('we-submit').textContent=t==='login'?'Entrar':'Criar conta';
}
function welcomeSkip(){
if(!confirm('Continuar sem sincronização? Seus dados ficam só neste dispositivo.'))return;
localStorage.setItem('shivao_welcome_dismissed','1');
document.getElementById('welcome-screen').style.display='none';
}
async function welcomeEmailSubmit(){
const email=document.getElementById('we-email').value.trim();
const pwd=document.getElementById('we-pwd').value;
const name=document.getElementById('we-name').value.trim();
const isSignup=document.getElementById('we-tab-signup').classList.contains('active');
const msg=document.getElementById('we-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(!email||!pwd){msg.textContent='Preencha email e senha';return}
if(isSignup&&pwd.length<8){msg.textContent='Senha mínima: 8 caracteres';return}
// Garante URL hardcoded
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
try{
if(isSignup)await authSignup(email,pwd,name);
else await authLogin(email,pwd);
msg.style.color='var(--algae)';msg.textContent='Logado! Sincronizando...';
welcomeFinish();
}catch(e){msg.textContent=e.message}
}
async function welcomeAdvancedSubmit(){
const url=document.getElementById('we-srv-url').value.trim();
const tok=document.getElementById('we-srv-token').value.trim();
const msg=document.getElementById('we-srv-msg');
if(!url||!tok){msg.textContent='Preencha URL e token';return}
state.cloud={url:url.replace(/\/$/,''),token:tok,lastSync:0};
saveState();
msg.style.color='var(--algae)';msg.textContent='Conectando...';
welcomeFinish();
}
function welcomeFinish(){
document.getElementById('welcome-screen').style.display='none';
// Conecta sync automaticamente
rtConnect();
refreshGoogleStatus();
// Pull inicial pra puxar dados existentes da conta
setTimeout(()=>autoPullNow().catch(()=>{}),500);
}
function initGoogleSignIn(){
if(!window.google?.accounts?.id||window._gsiInited)return;
try{
window.google.accounts.id.initialize({
client_id:GOOGLE_CLIENT_ID_FRONTEND,
callback:onGoogleCredential,
auto_select:false,
ux_mode:'popup',
});
window._gsiInited=true;
}catch(e){console.warn('[gsi] init failed',e)}
}
function welcomeGoogleClick(){
if(!window.google?.accounts?.id){
toast('Google Sign-In ainda carregando, aguarde 2s');
return;
}
initGoogleSignIn();
try{
window.google.accounts.id.prompt(()=>{});
// Fallback: render botão visível ao lado caso prompt seja bloqueado
}catch(e){
console.warn('[gsi] prompt failed',e);
toast('Erro: '+e.message);
}
}
async function onGoogleCredential(resp){
if(!resp?.credential){toast('Sem credential do Google');return}
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
try{
const r=await fetch(cloudUrl('/api/auth/google'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({credential:resp.credential}),
});
const j=await r.json();
if(!r.ok)throw new Error(j.error||'login failed');
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
toast('Bem-vindo, '+(j.user.name||j.user.email));
welcomeFinish();
if(typeof renderAuthBox==='function')renderAuthBox();
}catch(e){
console.warn('[google login]',e);
toast('Falha login Google: '+e.message);
}
}
// ============ REALTIME SYNC (WebSocket + auto push/pull) ============
// Conecta ao /ws do servidor, escuta notificações de mudança de outros devices
// e dispara pull/push automáticos. Reconnect com backoff exponencial.

View file

@ -1,7 +1,7 @@
// Shivao Service Worker — offline real
// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto.
// Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
const VERSION = 'shivao-v1.5.0';
const VERSION = 'shivao-v1.6.0';
const SHELL_CACHE = `shivao-shell-${VERSION}`;
const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
const WINDY_CACHE = `shivao-windy-${VERSION}`;

View file

@ -126,6 +126,48 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Login com Google (Sign-In) — recebe ID token, valida no Google, cria/loga user
app.post('/api/auth/google', async (req, res) => {
const { credential } = req.body || {};
if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' });
try {
// Valida o ID token via tokeninfo endpoint do Google (sem dependência adicional)
const r = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!r.ok) return res.status(401).json({ error: 'invalid_google_token' });
const info = await r.json();
// Confere que aud = nosso CLIENT_ID
const expectedAud = process.env.GOOGLE_CLIENT_ID || '';
if (expectedAud && info.aud !== expectedAud) {
return res.status(401).json({ error: 'token_audience_mismatch' });
}
if (!info.email_verified || info.email_verified === 'false') {
return res.status(403).json({ error: 'email_not_verified' });
}
const email = info.email;
const name = info.name || email.split('@')[0];
let user = db.findUserByEmail(email);
if (!user) {
// Auto-cria com senha aleatória inutilizável (login só via Google daqui pra frente)
const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
const hash = await hashPassword(randomPwd);
const id = db.createUser(email, hash, name);
db.audit(id, 'user_signup_google', 'user', String(id), { email }, req.ip);
user = db.findUserById(id);
}
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
res.json({
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
});
} catch (e) {
console.warn('[auth/google]', e.message);
res.status(500).json({ error: e.message });
}
});
// Plans + license info
app.get('/api/license', requireAuth, (req, res) => {
const lic = db.getActiveLicense(req.user.id) || { plan: 'free', status: 'active', expires_at: null };
@ -267,7 +309,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
});
// Atalho: /apk redireciona pra última APK release no Forgejo
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.5.0/Shivao-v1.5.0.apk';
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.6.0/Shivao-v1.6.0.apk';
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)