Compare commits

..

11 commits

Author SHA1 Message Date
PontualTech / Karlão
6e340cc733 feat(ui): refresh visual premium — design system v2 com depth, microinterações, polish
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
OVERLAY no <style> existente — mantém TODA a estrutura/funcionalidade,
moderniza profundamente o visual mantendo identidade marítima.

DESIGN SYSTEM
- Variables novas: spacing (s1-s8), radius (sm/md/lg/xl/pill), shadow scale (sh-1 a sh-5 + glow), transitions
- Surfaces elevated com backdrop-filter blur
- Border tokens (subtle/strong)

VISUAL UPGRADES
- Background limpo elegante (sem ruído pergaminho)
- Header gradient sutil + glow latão na compass-mark
- Tabs como pill nav modern com indicador animado e hover
- Cards com depth real (sombras multi-layer)
- GPS/Anchor card gradient diagonal com inner glow
- Buttons com lift hover, gradients sutis, focus ring
- Fields com border focus + glow latão
- Modal com blur backdrop + entrance polida + radius arredondado
- FAB com lift hover e scale
- Stats grid com cards individuais hovers
- Empty states elegantes
- Status pills arredondados
- Sensor widget com glassmorphism premium
- Auth box com glass effect

MICROINTERAÇÕES
- Todos hover transitions em 200ms cubic-bezier
- Lift de buttons (translateY -1px)
- Compass mark rotaciona 15deg no hover
- Cards levantam com box-shadow
- Media thumbs scale up com z-index

ACESSIBILIDADE
- Focus rings visíveis em tudo (outline 2px brass)
- prefers-reduced-motion honrado
- Cursor pointer garantido em interativos
- Scrollbars sutis customizadas

Sincronizado em app/ + server/public/. Sem mudança JS.
2026-04-27 20:39:23 -03:00
PontualTech / Karlão
af85d9d118 feat(store): screenshot 5 (termos de uso) capturado
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
2026-04-27 18:58:51 -03:00
PontualTech / Karlão
c46d30f7b9 feat(legal+ci): termos de uso + workflow Forgejo Actions pra build Android automático
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
LEGAL
- GET /termos: termos de uso completos (11 seções) com aviso CRÍTICO de limitação de responsabilidade pra navegação náutica (não substitui chartplotter/cartas/atenção do skipper)
- Reembolso CDC art. 49 (7 dias arrependimento) explicito
- Lei aplicável Brasil + foro SP

CI/CD
- .forgejo/workflows/build-android.yml: pipeline completo (checkout → JDK17 → Android SDK → npm install mobile/ → cap sync → gradle bundleRelease + assembleRelease → upload artifacts → release no manual)
- .forgejo/workflows/README.md: como configurar runner Forgejo no Coolify, secrets necessários (KEYSTORE_BASE64, KEYSTORE_PWD, FORGEJO_TOKEN), alternativas (Codemagic, GitHub Actions)
- Trigger automático em push em app/, mobile/, scripts/sync-html.mjs
- Trigger manual via botão Forgejo

VALIDADO
- Bundletool 1.17.2 instalado em ~/bundletool/
- AAB validado: arquivos OK
- APK por device gerado: 2.8-2.9 MB (vs 3.4 MB universal — Play Store entrega menor)
- ~/Downloads/Shivao-v1.2.0.apks (12MB, contém splits por arquitetura)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:57:01 -03:00
PontualTech / Karlão
43b6cc0d5d feat(store): screenshot 4 (política) capturado 2026-04-27 18:17:06 -03:00
PontualTech / Karlão
ad0e65b735 feat(legal+store): política de privacidade LGPD/GDPR + Play Store assets
- GET /politica — HTML com todas as 12 seções LGPD/GDPR + DPO
- mobile/play-store-listing.md: nome, descrições curta+completa PT-BR (4000 chars), categoria, tags, justificativas de permissions
- mobile/play-store-assets/icon-512.png — gerado via Playwright (renderiza icon.svg em 512x512)
- mobile/play-store-assets/feature-graphic.png — banner 1024x500 com logo + slogan + features
- mobile/play-store-assets/screenshots/ — telas reais do app (home, arquivo, upgrade modal)
2026-04-27 18:14:49 -03:00
PontualTech / Karlão
4202547d8e feat(mobile): Capacitor Android pronto — APK + AAB v1.2.0 buildados
PIPELINE COMPLETO QUE RODOU:
1. JDK 17 instalado em ~/jdk17 (portátil, sem admin)
2. Android SDK cmdline-tools + platform-34 + build-tools-34 + platform-tools instalados em ~/android-sdk
3. JAVA_HOME e ANDROID_HOME setados no PATH user
4. npm install + npx cap add android (estrutura Android gerada)
5. AndroidManifest.xml com 10 permissions (location background, vibrate, wake_lock, foreground service, post_notifications)
6. Keystore PERMANENTE shivao-release.keystore gerado via keytool (validade 10000d, RSA 2048)
7. build.gradle: signingConfigs.release + applicationId br.com.pontualtech.shivao + versionCode 2 + versionName 1.2.0
8. local.properties: sdk.dir=C:/Users/pontu/android-sdk (forward slashes — Java properties NÃO aceitam \)
9. ./gradlew bundleRelease assembleRelease — primeira build em 1m39s
10. APK 3.4MB + AAB 3.1MB — ambos signed com keystore permanente

ARTEFATOS:
- C:/Users/pontu/Downloads/Shivao-v1.2.0-capacitor.apk (3.4MB)
- C:/Users/pontu/Downloads/Shivao-v1.2.0-capacitor.aab (3.1MB) — pronto pra Play Store
- mobile/android/app/shivao-release.keystore (NÃO commitado, .gitignore protege)
- C:/Users/pontu/Downloads/Shivao-keystore-backup/shivao-release-CAPACITOR.keystore (BACKUP)

Release Forgejo v1.2.0 publicada com APK + AAB anexados.

Próximo: criar conta Google Play ($25 1×) e submeter o AAB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:34:19 -03:00
PontualTech / Karlão
7a523b8873 feat(mobile): scaffold Capacitor pra Android Play Store + adapter nativo
ESTRUTURA NOVA: mobile/ + scripts/sync-html.mjs

- mobile/package.json: Capacitor 6 + plugins (geolocation, local-notifications, network, preferences, status-bar)
- mobile/capacitor.config.json: appId br.com.pontualtech.shivao, allowNavigation pra OSM/Windy/CDNs
- mobile/.gitignore: protege keystore (NUNCA commitar chaves privadas)
- mobile/README.md: setup completo (JDK + Android Studio + keystore + build APK/AAB + Play Store submission + iOS futuro + troubleshooting)
- scripts/sync-html.mjs: copia app/diario-bordo.html → server/public + mobile/www (1 fonte da verdade)

ADAPTER NATIVO no HTML (sincronizado app/ + server/public/):
- isNative() / nativePlatform() detecta Capacitor
- nativeWatchPosition() usa Capacitor.Geolocation (background-capable) com fallback navigator.geolocation
- nativeNotify() usa Capacitor.LocalNotifications com fallback toast
- initServiceWorker() pula registro no Capacitor (WebView nativo já tem cache próprio)

NÃO INCLUI (ainda):
- Build local: precisa JDK 17 + Android Studio (~3GB) — instruções no README
- Keystore: gerar 1 vez via keytool (script no README)
- AAB pra Play Store: comandos no README
- Conta Google Play Developer: $25 1× pelo dono

Próximo passo manual: instalar JDK + Android Studio, rodar 'npm install && npx cap add android'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:02:34 -03:00
PontualTech / Karlão
ca9de52ae1 feat(billing): integração Asaas — checkout PIX/Cartão/Boleto + webhook + UI upgrade
BACKEND
- Nova tabela payments (user_id, asaas_payment_id, plan, cycle, value, billing_type, status, ...)
- Coluna users.asaas_customer_id (cache pra reaproveitar customer entre payments)
- server/src/billing.js: cliente Asaas v3 com getOrCreateCustomer, createPayment, getPixQrCode, status mapping
- Endpoint POST /api/billing/checkout — cria cobrança + retorna URL/QR PIX
- Endpoint GET /api/billing/payment/:id — verifica status, faz reconciliação se webhook falhou
- Endpoint POST /api/billing/asaas-webhook — ativa licença em RECEIVED/CONFIRMED, revoga em REFUNDED
- Endpoint GET /api/billing/payments — histórico do user
- 503 se ASAAS_API_KEY não configurado (graceful degradation)
- Webhook valida ASAAS_WEBHOOK_TOKEN (shared secret) se setado

FRONTEND (sincronizado app/ + server/public/)
- openUpgradeModal() — modal dinâmico com seleção plano (Pro/Captain) + ciclo (mensal/anual) + tipo (PIX/Cartão/Boleto)
- _doCheckout() — chama backend, exibe QR Code PIX OU link invoice
- checkPaymentStatus() — verifica e ativa licença quando pago

ENV VARS NECESSÁRIAS NO COOLIFY (próximo passo manual):
- ASAAS_API_KEY=$aact_prod_... (chave Asaas que Karlão já usa em outros projetos)
- ASAAS_API_URL=https://api.asaas.com/v3 (default)
- ASAAS_WEBHOOK_TOKEN=whsec_... (gere um valor aleatório, configure no painel Asaas → Integrações)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:55:08 -03:00
PontualTech / Karlão
a80adc7bdf fix(saas): migration robusta — recria state e anchor_session se schema legado, índices user_id após ALTER 2026-04-27 15:44:05 -03:00
PontualTech / Karlão
85b60a800c feat(saas): multi-tenant com login/cadastro + JWT + planos free/pro/captain
BACKEND
- bcryptjs + jsonwebtoken adicionados (JS puro, sem build nativo)
- Schema users + licenses, migration adiciona user_id em todas tabelas (state, media, anchor_session, alarm_log, shares, audit_log)
- User default id=1 (karlao@outlook.com) com plano captain — preserva uso pessoal pré-multi-tenant
- Endpoints /api/auth/{signup,login,refresh,me} + /api/license
- Middleware requireAuth aceita JWT OU BOAT_TOKEN (fallback legado mapeia ao user 1)
- TODAS rotas autenticadas atualizadas pra usar req.user.id (state, media, anchor, share, alarm, audit)
- Dead-man switch agora itera todos anchor_sessions ativos (multi-user)
- 3 planos definidos em auth.js: free (Âncora), pro (R$19/mês), captain (R$39/mês)

FRONTEND
- state.auth + state.license persistidos em localStorage
- cloudFetch usa JWT preferencialmente, fallback BOAT_TOKEN; auto-refresh em 401
- Nova seção 'Conta' no painel Arquivo: tabs Entrar/Cadastrar + status de plano + Logout + botão upgrade
- Sincronizado em app/ e server/public/

Backward-compat 100% preservada: app legado com BOAT_TOKEN continua funcionando como user default.
Próximo: webhook Asaas pra ativar licenças após pagamento PIX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:37:15 -03:00
PontualTech / Karlão
d1a2401048 feat(offline+sensors): Service Worker, bússola, barômetro, pré-cache de mapa
Implementa requisitos pra uso em áreas remotas:

OFFLINE REAL (Service Worker em server/public/sw.js)
- Pré-cache de shell (HTML, manifest, icon, Leaflet, fontes Google)
- Cache-first pra map tiles OSM (offline em alto-mar com tiles já visitados)
- Network-first pra Windy/Open-Meteo (com fallback ao cache)
- /api/* passa direto (não interferir em sync, heartbeat, auth)
- Skip-waiting + claim pra ativar imediatamente após install

SENSORES (sensor widget flutuante canto superior direito)
- Bússola via DeviceOrientationEvent (suporta iOS webkitCompassHeading + Android alpha)
- iOS: pede permission via gesture do usuário (botão 'Ativar bússola')
- Barômetro via Generic Sensor API (Android com sensor real, fallback gracioso)
- Tendência de pressão (subindo/caindo/estável) baseada em janela móvel
- Indicador de online/offline sempre visível

PRÉ-CACHE DE MAPA
- Botão 'Pré-cachear mapa' baixa tiles ~50km de raio (zooms 8-13, ~200 tiles)
- Comunicação page→SW via MessageChannel
- Limit 6 conexões paralelas (respeitando OSM tile policy)

DOCUMENTAÇÃO TERMÔMETRO: API web não tem termômetro de ambiente.
Solução: usar dado da Windy (já implementado) + cache offline via SW.

Sincronizado em app/ e server/public/ — single-file HTML preservado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:55:39 -03:00
79 changed files with 5857 additions and 132 deletions

View file

@ -0,0 +1,63 @@
# Forgejo Actions — CI/CD do Shivao
## Como ativar (uma vez)
### 1. Configure um runner Forgejo no Coolify
```bash
# Sub-domínio sugerido: actions.pontualtech.work (DNS A → Hetzner)
# Sobe via Coolify usando docker-compose:
```
```yaml
# Cole no Coolify → Pontualtech → New Resource → Docker Compose Empty
services:
forgejo-runner:
image: code.forgejo.org/forgejo/runner:6
restart: unless-stopped
environment:
FORGEJO_INSTANCE_URL: https://git.pontualtech.work
FORGEJO_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN} # pega em git.pontualtech.work/admin/runners
volumes:
- runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
volumes:
runner_data:
```
### 2. Configure secrets no Forgejo
`git.pontualtech.work/karlao/shivao-projeto/settings/actions/secrets`
| Secret | Como obter |
|---|---|
| `SHIVAO_KEYSTORE_BASE64` | `base64 -w0 ~/Downloads/Shivao-keystore-backup/shivao-release-CAPACITOR.keystore` |
| `SHIVAO_KEYSTORE_PWD` | `ShivaoKeystore2026!` (a senha que você definiu) |
| `FORGEJO_TOKEN` | Settings → Applications → Generate New Token (permissions: write:repository) |
### 3. Triggers do workflow
- **Auto:** push em `master` que toque em `app/`, `mobile/` ou `scripts/sync-html.mjs`
- **Manual:** botão "Run workflow" no painel Forgejo Actions
## Output
Cada build gera:
- APK assinado: `Shivao-v{X.Y.Z}.apk`
- AAB assinado: `Shivao-v{X.Y.Z}.aab`
- Anexados como artifact (download via UI Forgejo)
- Se trigger for manual: cria release `v{X.Y.Z}-ci` no repo
## Vantagens vs build local
- ✅ Zero setup local — Karlão pode editar HTML do iPad e o build roda no servidor
- ✅ Imutável e reproduzível
- ✅ Self-hosted (alinhado com `feedback_self_hosted.md`)
- ✅ Logs centralizados
- ⚠️ Custo: 1 container Coolify rodando (low-resource, ~50MB RAM idle)
## Alternativas se runner não funcionar
- **Codemagic:** 500min grátis/mês, conecta ao Forgejo via webhook
- **GitHub Actions (free tier):** mirror do repo no GitHub → workflow padrão
- **Build local:** `cd mobile && npm run android:build:aab` (já documentado em mobile/README.md)

View file

@ -0,0 +1,97 @@
name: Build Android (APK + AAB)
on:
push:
branches: [master]
paths:
- 'app/**'
- 'mobile/**'
- 'scripts/sync-html.mjs'
- '.forgejo/workflows/build-android.yml'
workflow_dispatch: {} # botão manual no painel Forgejo
jobs:
build-android:
runs-on: ubuntu-latest
permissions:
contents: write # pra upload de release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-34 build-tools;34.0.0 platform-tools'
- name: Install root deps (sync script)
run: |
cd "${{ github.workspace }}"
# Sem package.json no root, script é puro Node ESM
node --version
- name: Sync HTML (1 fonte → server + mobile)
run: node scripts/sync-html.mjs
- name: Install Capacitor deps
working-directory: mobile
run: npm ci --ignore-scripts
- name: Capacitor sync
working-directory: mobile
run: npx cap sync android
- name: Decode keystore from secrets
run: |
echo "${{ secrets.SHIVAO_KEYSTORE_BASE64 }}" | base64 --decode > mobile/android/app/shivao-release.keystore
ls -lh mobile/android/app/shivao-release.keystore
- name: Build APK + AAB (release, signed)
working-directory: mobile/android
env:
SHIVAO_KEYSTORE_PWD: ${{ secrets.SHIVAO_KEYSTORE_PWD }}
run: |
chmod +x ./gradlew
./gradlew bundleRelease assembleRelease --no-daemon --stacktrace
- name: Rename artifacts with version
run: |
VERSION=$(grep "versionName" mobile/android/app/build.gradle | head -1 | grep -oE '"[^"]+"' | tr -d '"')
echo "Version: $VERSION"
cp mobile/android/app/build/outputs/apk/release/app-release.apk Shivao-v${VERSION}.apk
cp mobile/android/app/build/outputs/bundle/release/app-release.aab Shivao-v${VERSION}.aab
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Upload APK + AAB as artifacts (CI)
uses: actions/upload-artifact@v3
with:
name: shivao-android-${{ env.VERSION }}
path: |
Shivao-v${{ env.VERSION }}.apk
Shivao-v${{ env.VERSION }}.aab
- name: Create Forgejo release
if: github.event_name == 'workflow_dispatch'
uses: actions/forgejo-release@v2
with:
direction: upload
tag: v${{ env.VERSION }}-ci
title: "Shivao v${{ env.VERSION }} (CI build)"
files: |
Shivao-v${{ env.VERSION }}.apk
Shivao-v${{ env.VERSION }}.aab
token: ${{ secrets.FORGEJO_TOKEN }}
url: https://git.pontualtech.work
repo: karlao/shivao-projeto

View file

@ -774,6 +774,435 @@ header{
body{background:#fff;background-image:none} body{background:#fff;background-image:none}
.entry{break-inside:avoid;border:1px solid #999} .entry{break-inside:avoid;border:1px solid #999}
} }
/* ═══════════════════════════════════════════════════════
MODERN PROFESSIONAL OVERLAY · v2 — Apr 2026
Mantém identidade marítima (cores Fraunces + Manrope + JetBrains Mono),
mas moderniza profundamente: depth, microinterações, polish premium.
═══════════════════════════════════════════════════════ */
:root{
/* spacing scale (8pt base) */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
/* radius scale (sutis — alinhados ao tom marítimo) */
--r-sm:4px; --r-md:8px; --r-lg:12px; --r-xl:16px; --r-pill:9999px;
/* shadow scale (depth profissional) */
--sh-1:0 1px 2px rgba(14,42,61,.06), 0 1px 1px rgba(14,42,61,.04);
--sh-2:0 2px 4px rgba(14,42,61,.06), 0 4px 8px rgba(14,42,61,.05);
--sh-3:0 4px 8px rgba(14,42,61,.07), 0 8px 16px rgba(14,42,61,.06);
--sh-4:0 8px 16px rgba(14,42,61,.08), 0 16px 32px rgba(14,42,61,.08);
--sh-5:0 16px 32px rgba(14,42,61,.10), 0 32px 64px rgba(14,42,61,.10);
--sh-glow:0 0 0 4px rgba(160,120,50,.12);
--sh-glow-blue:0 0 0 4px rgba(31,91,118,.18);
/* transitions */
--t-fast:120ms cubic-bezier(.4,0,.2,1);
--t-base:200ms cubic-bezier(.4,0,.2,1);
--t-slow:320ms cubic-bezier(.4,0,.2,1);
/* superfícies elevadas premium */
--surface-1:#fbf5e2;
--surface-2:#ffffff;
--surface-elevated:rgba(255,255,255,.78);
--border-subtle:rgba(184,156,108,.22);
--border-strong:rgba(184,156,108,.55);
}
/* ── BACKGROUND limpo elegante (sem ruído) ── */
body{
background:
radial-gradient(ellipse 1200px 800px at 50% -200px, #f8eed5 0%, transparent 60%),
linear-gradient(180deg, #efe5cd 0%, #e7d9b6 100%);
background-attachment:fixed;
background-image:
radial-gradient(ellipse 1200px 800px at 50% -200px, #f8eed5 0%, transparent 60%),
linear-gradient(180deg, #efe5cd 0%, #e7d9b6 100%);
}
/* ── HEADER premium com glow sutil + gradient ── */
header{
background:linear-gradient(180deg, #0e2a3d 0%, #143447 100%);
box-shadow:
inset 0 1px 0 rgba(200,159,84,.30),
inset 0 -1px 0 rgba(160,120,50,.55),
0 4px 24px rgba(14,42,61,.18),
0 12px 48px rgba(14,42,61,.10);
border-bottom:none;
}
.header-row{padding:2px 0}
.boat-name{
text-shadow:0 1px 2px rgba(0,0,0,.25);
letter-spacing:-.015em;
}
.compass-mark{
filter:drop-shadow(0 2px 8px rgba(200,159,84,.35));
transition:transform var(--t-base);
}
.compass-mark:hover{transform:rotate(15deg)}
/* ── TABS modernas: pill nav com indicador animado ── */
.tabs{
background:rgba(239,229,205,.92);
backdrop-filter:blur(12px) saturate(1.2);
-webkit-backdrop-filter:blur(12px) saturate(1.2);
border-bottom:1px solid var(--border-subtle);
padding:8px 4px 0;
gap:2px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset;
}
.tab{
border-radius:var(--r-md) var(--r-md) 0 0;
padding:13px 18px 14px;
transition:color var(--t-base), background var(--t-base);
position:relative;
}
.tab:hover{
color:var(--ink-mid);
background:rgba(255,255,255,.4);
}
.tab.active{
color:var(--ink-deep);
background:linear-gradient(180deg, rgba(255,255,255,.85), rgba(255,255,255,.5));
}
.tab.active::after{
height:3px;left:14px;right:14px;bottom:0;
background:linear-gradient(90deg, var(--brass), var(--brass-bright));
border-radius:2px 2px 0 0;
box-shadow:0 0 8px rgba(160,120,50,.4);
}
/* ── CARDS com depth real (sombra multi-layer) ── */
.gps-card,.anchor-card,.export-card,.empty,.entry,.contact-card{
border-radius:var(--r-lg);
box-shadow:var(--sh-2);
transition:box-shadow var(--t-base), transform var(--t-base);
overflow:hidden;
}
.entry{
border-radius:var(--r-md);
box-shadow:var(--sh-1);
border-color:var(--border-subtle);
}
.entry:hover{box-shadow:var(--sh-2)}
.gps-card,.anchor-card{
border-radius:var(--r-lg);
background:linear-gradient(165deg, #0e2a3d 0%, #1a3d54 100%);
border:1px solid rgba(200,159,84,.4);
box-shadow:
inset 0 1px 0 rgba(200,159,84,.2),
var(--sh-3),
0 0 32px rgba(31,91,118,.15);
}
.gps-card::before,.anchor-card::before{display:none}
.gps-card.idle{
background:linear-gradient(165deg, #fbf5e2 0%, #f3e7c4 100%);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-2);
}
.gps-stats,.anchor-stats-bar{
background:rgba(0,0,0,.15);
padding:14px;
border-radius:var(--r-md);
margin:12px 0 14px;
}
.gps-card.idle .gps-stats{background:rgba(184,156,108,.08)}
.export-card,.empty{
background:var(--surface-elevated);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border-radius:var(--r-lg);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-2);
}
.export-card:hover{box-shadow:var(--sh-3)}
/* ── BUTTONS premium com lift e ripple ── */
.btn{
border-radius:var(--r-md);
transition:all var(--t-base);
font-weight:600;
letter-spacing:.12em;
position:relative;
overflow:hidden;
}
.btn:hover{
transform:translateY(-1px);
box-shadow:var(--sh-2);
}
.btn:active{transform:translateY(0); box-shadow:var(--sh-1)}
.btn:focus-visible{
outline:none;
box-shadow:var(--sh-glow);
}
.btn-primary{
background:linear-gradient(180deg, #143a52 0%, #0e2a3d 100%);
border-color:rgba(0,0,0,.4);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.10),
var(--sh-1);
}
.btn-primary:hover{
background:linear-gradient(180deg, #1a4a66 0%, #143a52 100%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.15),
var(--sh-3);
}
.btn-brass{
background:linear-gradient(180deg, #b88a3c 0%, #8d6826 100%);
border-color:rgba(0,0,0,.25);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.18),
var(--sh-1);
}
.btn-brass:hover{
background:linear-gradient(180deg, #c89954 0%, #a07832 100%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.25),
var(--sh-3);
}
.btn-danger{
background:transparent;
border:1px solid var(--storm);
color:var(--storm);
}
.btn-danger:hover{
background:linear-gradient(180deg, #a04545 0%, #8c3434 100%);
color:#fff;
border-color:transparent;
}
/* ── FIELDS modernos com focus ring ── */
.field input,.field textarea,.field select{
border-radius:var(--r-md);
background:var(--surface-2);
border:1px solid var(--border-subtle);
padding:12px 14px;
font-size:15px;
transition:border-color var(--t-base), box-shadow var(--t-base), background var(--t-base);
}
.field input:hover,.field textarea:hover,.field select:hover{
border-color:var(--border-strong);
}
.field input:focus,.field textarea:focus,.field select:focus{
border-color:var(--brass);
background:#fff;
box-shadow:var(--sh-glow);
}
.field-label{font-size:11px; font-weight:600; color:var(--ink-mid); margin-bottom:6px}
/* ── MODAL com depth maior + entrance polida ── */
.modal-backdrop{
background:rgba(14,42,61,.65);
backdrop-filter:blur(8px) saturate(1.1);
-webkit-backdrop-filter:blur(8px) saturate(1.1);
}
.modal{
border-radius:var(--r-xl) var(--r-xl) 0 0;
border-top:none;
box-shadow:
0 -4px 12px rgba(0,0,0,.05),
0 -16px 48px rgba(14,42,61,.25);
background-image:none;
background:linear-gradient(180deg, #fcf6e4 0%, #f8f0d4 100%);
}
@media(min-width:600px){
.modal{
border-radius:var(--r-xl);
box-shadow:0 24px 64px rgba(14,42,61,.30), 0 8px 16px rgba(14,42,61,.10);
}
}
.modal-head{
background:transparent;
padding:18px 22px;
border-bottom:1px solid var(--border-subtle);
}
.modal-body{padding:22px}
.modal-foot{
background:rgba(0,0,0,.02);
padding:16px 22px;
}
/* ── FAB redesenhado: floating elegante ── */
.fab{
border-radius:var(--r-pill) !important;
background:linear-gradient(135deg, #b88a3c 0%, #8d6826 100%) !important;
box-shadow:
0 4px 12px rgba(160,120,50,.4),
0 12px 32px rgba(14,42,61,.15),
inset 0 1px 0 rgba(255,255,255,.25) !important;
transition:transform var(--t-base), box-shadow var(--t-base);
}
.fab:hover{
transform:translateY(-2px) scale(1.05);
box-shadow:
0 8px 20px rgba(160,120,50,.5),
0 16px 40px rgba(14,42,61,.20),
inset 0 1px 0 rgba(255,255,255,.30);
}
.fab:active{transform:translateY(0) scale(1)}
/* ── STATS GRID polido ── */
.stats{
border-radius:var(--r-lg);
background:transparent;
border:none;
gap:10px;
box-shadow:none;
display:grid;
}
.stat{
border-radius:var(--r-md);
background:var(--surface-elevated);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-1);
padding:16px 16px 14px;
transition:transform var(--t-base), box-shadow var(--t-base);
}
.stat:hover{transform:translateY(-2px); box-shadow:var(--sh-2)}
.stat::before{
height:3px; border-radius:2px;
background:linear-gradient(90deg, var(--brass), var(--brass-bright));
transform:scaleX(.5);
transition:transform var(--t-base);
}
.stat:hover::before{transform:scaleX(1)}
/* ── EMPTY STATE elegante ── */
.empty{padding:56px 28px; border-radius:var(--r-lg)}
.empty::before,.empty::after{display:none}
.empty-rose{
width:64px; height:64px;
filter:drop-shadow(0 4px 12px rgba(160,120,50,.25));
margin-bottom:18px;
}
.empty-title{font-size:21px}
.empty-text{font-size:14px; line-height:1.65; opacity:.85}
/* ── STATUS PILLS arredondados ── */
.status-pill{
border-radius:var(--r-pill);
padding:3px 10px;
font-size:9.5px;
letter-spacing:.16em;
}
/* ── ALERTS com sombra sutil ── */
.alert{
border-radius:var(--r-md);
border:1px solid var(--border-subtle);
border-left-width:4px;
box-shadow:var(--sh-1);
padding:14px 16px;
transition:transform var(--t-base);
}
.alert:hover{transform:translateX(2px)}
/* ── PASSENGER PILLS modernos ── */
.pax-pill,.pax-tag,.channel-pill{
border-radius:var(--r-pill);
padding:4px 12px;
}
/* ── ICON BUTTONS polidos ── */
.icon-btn{
border-radius:var(--r-md);
transition:all var(--t-base);
}
.icon-btn:hover{
background:rgba(184,156,108,.15);
transform:scale(1.08);
}
/* ── MEDIA THUMBS ── */
.media-thumb,.media-item{
border-radius:var(--r-md);
transition:transform var(--t-base), box-shadow var(--t-base);
}
.media-thumb:hover,.media-item:hover{
transform:scale(1.04);
box-shadow:var(--sh-2);
z-index:2;
}
/* ── SENSOR WIDGET premium (top-right) ── */
#sensors-widget{
border-radius:var(--r-lg) !important;
background:linear-gradient(165deg, rgba(14,42,61,.92), rgba(20,58,82,.92)) !important;
backdrop-filter:blur(20px) saturate(1.4);
-webkit-backdrop-filter:blur(20px) saturate(1.4);
box-shadow:
0 4px 12px rgba(0,0,0,.15),
0 8px 24px rgba(14,42,61,.18),
inset 0 1px 0 rgba(200,159,84,.2) !important;
border:1px solid rgba(200,159,84,.18);
transition:transform var(--t-base), box-shadow var(--t-base);
}
#sensors-widget:hover{
transform:translateY(-1px);
box-shadow:
0 8px 16px rgba(0,0,0,.20),
0 16px 32px rgba(14,42,61,.22);
}
/* ── AUTH BOX polido ── */
#auth-box{
background:linear-gradient(165deg, rgba(255,255,255,.5), rgba(255,255,255,.2));
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border-radius:var(--r-md);
padding:14px;
margin-top:14px !important;
border:1px solid var(--border-subtle);
border-top:1px solid var(--border-subtle) !important;
}
/* ── CURSOR pointer em interativos críticos ── */
.tab,.btn,.fab,.icon-btn,.media-btn,.media-thumb,.media-item,.alert,
.entry-actions button,.modal-head button,.pax-tag button{
cursor:pointer;
}
/* ── FOCUS visible global ── */
*:focus-visible{
outline:2px solid var(--brass);
outline-offset:2px;
border-radius:var(--r-sm);
}
/* ── REDUCED MOTION (accessibility) ── */
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{
animation-duration:.01ms !important;
animation-iteration-count:1 !important;
transition-duration:.01ms !important;
}
}
/* ── LARGE SCREENS: mais respiro ── */
@media(min-width:780px){
.container{padding:32px 24px 96px}
.stats{grid-template-columns:repeat(4,1fr)}
}
/* ── SCROLLBAR sutil (Firefox + WebKit) ── */
*{scrollbar-width:thin; scrollbar-color:var(--brass) transparent}
::-webkit-scrollbar{width:8px; height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(160,120,50,.35); border-radius:var(--r-pill)}
::-webkit-scrollbar-thumb:hover{background:rgba(160,120,50,.55)}
/* ── TYPOGRAPHY refinement ── */
.boat-name{font-size:26px}
.entry-title{font-size:20px; line-height:1.25}
.modal-head h3{font-size:20px}
.empty-title{margin-bottom:6px}
/* ── SECTION HEADER sutilmente refinado ── */
.section-header h2{font-size:11px; font-weight:600; color:var(--ink-mid)}
/* ── TOOLBAR refinada ── */
.toolbar{gap:10px; margin-bottom:18px}
</style> </style>
</head> </head>
<body> <body>
@ -899,6 +1328,7 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button> <button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button>
</div> </div>
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div> <div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div> </div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))"> <div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div> <div class="export-card-title">📡 Compartilhar posição em tempo real</div>
@ -1289,7 +1719,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div> <div class="zone-toast" id="zone-toast"></div>
<script> <script>
const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}}; const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const STORAGE_KEY='diario_bordo_v3'; const STORAGE_KEY='diario_bordo_v3';
const TRACKING_KEY='diario_tracking_v3'; const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3'; const ANCHOR_KEY='diario_anchor_v3';
@ -1437,7 +1867,7 @@ function renderGPSBanner(){
function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})} function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()} function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
function openModal(id){document.getElementById(id).classList.add('show')} function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')} function closeModal(id){document.getElementById(id).classList.remove('show')}
@ -1821,6 +2251,8 @@ async function updateStorageInfo(){
loadTrackingState(); loadTrackingState();
loadAnchorState(); loadAnchorState();
initBattery(); initBattery();
initServiceWorker();
initSensorWidget();
// tenta auto-fetch do tempo após pequeno delay // tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000); setTimeout(maybeAutoFetchWeather,3000);
})(); })();
@ -2356,11 +2788,198 @@ function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
async function cloudFetch(path,opts={}){ async function cloudFetch(path,opts={}){
if(!cloudConfigured())throw new Error('Nuvem não configurada'); if(!cloudConfigured())throw new Error('Nuvem não configurada');
const r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${state.cloud.token}`,'Content-Type':'application/json',...(opts.headers||{})}}); // Usa JWT se logado (multi-tenant SaaS), senão BOAT_TOKEN legado (single-tenant pessoal)
if(!r.ok)throw new Error(`HTTP ${r.status}`); const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
const doFetch=()=>fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth}`,'Content-Type':'application/json',...(opts.headers||{})}});
let r=await doFetch();
// 401 com JWT? Tenta refresh + retry 1×
if(r.status===401&&state.auth&&state.auth.refreshToken){
const ok=await authRefresh();
if(ok){const auth2=state.auth.accessToken;r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth2}`,'Content-Type':'application/json',...(opts.headers||{})}})}
}
if(!r.ok){let detail='';try{const j=await r.clone().json();detail=j.error||JSON.stringify(j.issues||{})}catch{}throw new Error(`HTTP ${r.status}${detail?' · '+detail:''}`)}
return r; return r;
} }
// ===== Auth (multi-tenant SaaS — Login/Signup) =====
async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/signup'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password,name})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authLogin(email,password){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authRefresh(){
if(!state.auth||!state.auth.refreshToken)return false;
try{
const r=await fetch(cloudUrl('/api/auth/refresh'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refreshToken:state.auth.refreshToken})});
if(!r.ok){state.auth=null;saveState();return false}
const j=await r.json();
state.auth.accessToken=j.accessToken;
saveState();
return true;
}catch{return false}
}
function authLogout(){
state.auth=null;
state.license=null;
saveState();
renderAuthBox();
toast('Sessão encerrada');
}
async function refreshLicense(){
try{
const r=await cloudFetch('/api/license');
state.license=await r.json();
saveState();
}catch(e){console.warn('license fetch:',e.message)}
}
function renderAuthBox(){
const box=document.getElementById('auth-box');
if(!box)return;
if(state.auth&&state.auth.user){
const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="openUpgradeModal()">⚡ Fazer upgrade pra Pro</button>':''}`;
}else{
box.innerHTML=`
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-sm" id="auth-tab-login" onclick="document.getElementById('auth-form-login').style.display='block';document.getElementById('auth-form-signup').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-signup').classList.remove('btn-brass')" style="flex:1">Entrar</button>
<button class="btn btn-sm" id="auth-tab-signup" onclick="document.getElementById('auth-form-signup').style.display='block';document.getElementById('auth-form-login').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-login').classList.remove('btn-brass')" style="flex:1">Cadastrar</button>
</div>
<div id="auth-form-login">
<div class="field"><label class="field-label">Email</label><input type="email" id="login-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Senha</label><input type="password" id="login-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthLoginClick()">Entrar</button>
</div>
<div id="auth-form-signup" style="display:none">
<div class="field"><label class="field-label">Email</label><input type="email" id="signup-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Nome (opcional)</label><input type="text" id="signup-name" placeholder="Seu nome"></div>
<div class="field"><label class="field-label">Senha (mín 8 chars)</label><input type="password" id="signup-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthSignupClick()">Criar conta</button>
</div>
<div id="auth-msg" style="margin-top:8px;font-size:11px;color:var(--storm)"></div>`;
document.getElementById('auth-tab-login').classList.add('btn-brass');
}
}
async function onAuthLoginClick(){
const e=document.getElementById('login-email').value.trim();
const p=document.getElementById('login-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
}
// ===== Upgrade modal (Asaas billing) =====
let _upgradeChosen={plan:'pro',cycle:'yearly',billingType:'PIX'};
async function openUpgradeModal(){
if(!state.auth){toast('Faça login primeiro');return}
// Cria modal dinamicamente (não precisa adicionar HTML no body)
const existing=document.getElementById('upgrade-modal');
if(existing)existing.remove();
const m=document.createElement('div');
m.id='upgrade-modal';
m.className='modal-backdrop';
m.style.cssText='align-items:center;justify-content:center;display:flex;z-index:9999';
m.innerHTML=`<div class="modal" style="max-width:420px;padding:24px"><h3 style="margin:0 0 12px;font-family:var(--f-display),Georgia,serif;font-style:italic;color:var(--brass)">Upgrade · escolha seu plano</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:12px 0">
<button id="up-pro" class="btn" onclick="_upgradeChoose('pro')" style="padding:10px;text-align:left"><strong>Pro</strong><br><small style="opacity:.8">R\$19/mês ou R\$149/ano</small></button>
<button id="up-captain" class="btn" onclick="_upgradeChoose('captain')" style="padding:10px;text-align:left"><strong>Captain</strong><br><small style="opacity:.8">R\$39/mês ou R\$299/ano</small></button>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button id="up-monthly" class="btn btn-sm" onclick="_upgradeCycle('monthly')" style="flex:1">Mensal</button>
<button id="up-yearly" class="btn btn-sm" onclick="_upgradeCycle('yearly')" style="flex:1">Anual (-35%)</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<button id="up-pix" class="btn btn-sm" onclick="_upgradeType('PIX')" style="flex:1">PIX (instantâneo)</button>
<button id="up-cc" class="btn btn-sm" onclick="_upgradeType('CREDIT_CARD')" style="flex:1">Cartão</button>
<button id="up-bol" class="btn btn-sm" onclick="_upgradeType('BOLETO')" style="flex:1">Boleto</button>
</div>
<div id="up-summary" style="font-family:var(--f-mono);font-size:12px;color:var(--ink);background:var(--bg-aged);padding:10px;border-radius:4px;margin-bottom:12px"></div>
<button class="btn btn-block btn-primary" onclick="_doCheckout()">Continuar pra pagamento</button>
<button class="btn btn-block" onclick="document.getElementById('upgrade-modal').remove()" style="margin-top:6px">Cancelar</button>
<div id="up-result" style="margin-top:14px"></div>
</div>`;
document.body.appendChild(m);
_upgradeRefresh();
}
function _upgradeChoose(p){_upgradeChosen.plan=p;_upgradeRefresh()}
function _upgradeCycle(c){_upgradeChosen.cycle=c;_upgradeRefresh()}
function _upgradeType(t){_upgradeChosen.billingType=t;_upgradeRefresh()}
function _upgradeRefresh(){
const c=_upgradeChosen;
['up-pro','up-captain'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
['up-monthly','up-yearly'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
['up-pix','up-cc','up-bol'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
document.getElementById('up-'+c.plan)?.classList.add('btn-brass');
document.getElementById('up-'+c.cycle)?.classList.add('btn-brass');
document.getElementById('up-'+(c.billingType==='PIX'?'pix':c.billingType==='CREDIT_CARD'?'cc':'bol'))?.classList.add('btn-brass');
const prices={pro:{monthly:19,yearly:149},captain:{monthly:39,yearly:299}};
const v=prices[c.plan][c.cycle];
document.getElementById('up-summary').textContent=`Plano ${c.plan==='pro'?'Pro':'Captain'} · ${c.cycle==='monthly'?'mensal':'anual'} · R$ ${v.toFixed(2)} via ${c.billingType==='PIX'?'PIX':c.billingType==='CREDIT_CARD'?'Cartão':'Boleto'}`;
}
async function _doCheckout(){
const out=document.getElementById('up-result');out.innerHTML='<em>Criando cobrança…</em>';
try{
const r=await cloudFetch('/api/billing/checkout',{method:'POST',body:JSON.stringify(_upgradeChosen)});
const data=await r.json();
let html='';
if(data.pix&&data.pix.qrCode){
html=`<div style="text-align:center"><strong>Escaneie o QR Code PIX:</strong><br><img src="data:image/png;base64,${data.pix.qrCode}" style="max-width:240px;margin:10px auto;border:4px solid #fff;border-radius:8px"><br><div style="font-family:var(--f-mono);font-size:10px;word-break:break-all;background:var(--bg-aged);padding:6px;border-radius:4px;margin:6px 0"><strong>Copia e cola:</strong><br>${data.pix.payload||''}</div><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${data.pix.payload||''}').then(()=>toast('PIX copiado!'))">Copiar PIX</button></div>`;
}else if(data.invoiceUrl){
html=`<a href="${data.invoiceUrl}" target="_blank" class="btn btn-block btn-primary">Abrir página de pagamento ↗</a>`;
}
html+=`<div style="margin-top:10px;font-size:11px;opacity:.8">Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.</div>`;
html+=`<button class="btn btn-block" style="margin-top:10px" onclick="checkPaymentStatus('${data.paymentId}')">Verificar pagamento</button>`;
out.innerHTML=html;
}catch(e){out.innerHTML='<span style="color:var(--storm)">Erro: '+e.message+'</span>'}
}
async function checkPaymentStatus(paymentId){
try{
const r=await cloudFetch('/api/billing/payment/'+paymentId);
const p=await r.json();
if(['RECEIVED','CONFIRMED','RECEIVED_IN_CASH'].includes(p.status)){
toast('Pago! Licença ativada 🎉');
await refreshLicense();renderAuthBox();
document.getElementById('upgrade-modal')?.remove();
}else{
toast('Status: '+p.status);
}
}catch(e){toast('Erro: '+e.message)}
}
async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim();
const p=document.getElementById('signup-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(p.length<8){msg.textContent='Senha precisa de no mínimo 8 caracteres';return}
try{await authSignup(e,p,n);msg.style.color='var(--algae)';msg.textContent='Conta criada e logado!';toast('Conta criada')}catch(err){msg.textContent=err.message}
}
function bindCloudInputs(){ function bindCloudInputs(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token'); const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
if(!u||!t)return; if(!u||!t)return;
@ -2951,6 +3570,216 @@ async function testWindyKey(){
} }
} }
// ===== Capacitor adapter (detecta nativo + usa APIs nativas com fallback Web) =====
const isNative=()=>!!(window.Capacitor&&window.Capacitor.isNativePlatform&&window.Capacitor.isNativePlatform());
const nativePlatform=()=>(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'web';
// Geolocation: Capacitor.Geolocation (background-capable) > navigator.geolocation
async function nativeWatchPosition(onUpdate,onError,opts){
if(isNative()&&window.Capacitor.Plugins.Geolocation){
try{
const{Geolocation}=window.Capacitor.Plugins;
// Pede permission upfront
const perm=await Geolocation.requestPermissions({permissions:['location']}).catch(()=>({location:'denied'}));
if(perm.location==='denied'){onError({code:1,PERMISSION_DENIED:1,message:'Permissão negada'});return null}
const id=await Geolocation.watchPosition({enableHighAccuracy:!!opts?.enableHighAccuracy,timeout:opts?.timeout||15000,maximumAge:opts?.maximumAge||0},(pos,err)=>{
if(err){onError({code:err.code||3,message:err.message||'GPS erro',PERMISSION_DENIED:1,POSITION_UNAVAILABLE:2,TIMEOUT:3});return}
onUpdate({coords:{latitude:pos.coords.latitude,longitude:pos.coords.longitude,accuracy:pos.coords.accuracy,speed:pos.coords.speed},timestamp:pos.timestamp});
});
return{native:true,id};
}catch(e){console.warn('[native gps] fallback to web:',e.message)}
}
// Fallback web
const id=navigator.geolocation.watchPosition(onUpdate,onError,opts);
return{native:false,id};
}
async function nativeClearWatch(handle){
if(!handle)return;
if(handle.native&&isNative()){try{await window.Capacitor.Plugins.Geolocation.clearWatch({id:handle.id})}catch(e){}}
else if(handle.id!=null)navigator.geolocation.clearWatch(handle.id);
}
// Local notifications: nativo no Android, fallback toast no web
async function nativeNotify(title,body,id){
if(isNative()&&window.Capacitor.Plugins.LocalNotifications){
try{
const{LocalNotifications}=window.Capacitor.Plugins;
await LocalNotifications.requestPermissions().catch(()=>{});
await LocalNotifications.schedule({notifications:[{id:id||Date.now()%2147483647,title,body,sound:null,smallIcon:'ic_stat_icon_config_sample'}]});
return;
}catch(e){console.warn('[native notify]',e.message)}
}
toast(title+(body?': '+body:''));
}
// ===== Service Worker (offline real — só registra no Web, no Capacitor o WebView usa cache nativo) =====
function initServiceWorker(){
if(isNative())return; // Capacitor não usa SW (tem cache próprio + APIs nativas)
if(!('serviceWorker' in navigator))return;
navigator.serviceWorker.register('/sw.js').then(reg=>{
console.log('[SW] registered:',reg.scope);
}).catch(e=>console.warn('[SW] failed:',e.message));
}
// ===== Sensores: Bússola + Barômetro + Status Offline =====
const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
function initSensorWidget(){
// Cria widget flutuante (canto superior direito, abaixo do header)
const w=document.createElement('div');
w.id='sensors-widget';
w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
w.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer" id="sw-toggle"><span id="sw-online" style="color:#3f7768"></span><span id="sw-compass">🧭 ---°</span><span id="sw-pressure" style="opacity:.7">🌡 ---</span><span style="opacity:.5;font-size:10px"></span></div><div id="sw-extra" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid rgba(160,120,50,.3);font-size:10px;line-height:1.5"></div>';
document.body.appendChild(w);
document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
// Online/offline status
updateOnlineStatus();
window.addEventListener('online',updateOnlineStatus);
window.addEventListener('offline',updateOnlineStatus);
// Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
tryStartCompass();
tryStartBarometer();
}
function toggleSensorPanel(){
const ex=document.getElementById('sw-extra');
const open=ex.style.display==='none';
ex.style.display=open?'block':'none';
if(open)renderSensorPanel();
}
function renderSensorPanel(){
const ex=document.getElementById('sw-extra');
const online=navigator.onLine;
const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?' caindo':' estável');
ex.innerHTML=`
<div>Conexão: <strong>${online?'online':'offline'}</strong></div>
<div>Bússola: <strong>${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}</strong></div>
<div>Pressão: <strong>${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}</strong></div>
<div>Tendência: <strong>${trend}</strong></div>
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
${sensors.heading===null?'<button onclick="requestCompassPermission()" style="background:#a07832;color:#0e2a3d;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Ativar bússola</button>':''}
<button onclick="precacheCurrentMapArea()" style="background:#3f7768;color:#efe5cd;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Pré-cachear mapa</button>
<button onclick="showCacheSizes()" style="background:#0e2a3d;color:#efe5cd;border:1px solid #a07832;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Cache</button>
</div>
<div id="sw-cache-info" style="margin-top:6px;font-size:9px;opacity:.7"></div>`;
}
function updateOnlineStatus(){
const dot=document.getElementById('sw-online');
if(!dot)return;
dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
dot.title=navigator.onLine?'Online':'Offline — usando cache';
}
function headingToCardinal(deg){
const dirs=['N','NE','L','SE','S','SO','O','NO'];
return dirs[Math.round(deg/45)%8];
}
function tryStartCompass(){
// iOS Safari requer permission via gesture do usuário (DeviceOrientationEvent.requestPermission)
// Android Chrome: funciona direto sem permission
if(typeof DeviceOrientationEvent==='undefined')return;
if(typeof DeviceOrientationEvent.requestPermission==='function'){
// iOS — aguarda usuário clicar "Ativar bússola"
return;
}
// Android: pode iniciar direto
attachCompassListener();
}
function requestCompassPermission(){
if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
if(typeof DeviceOrientationEvent.requestPermission==='function'){
DeviceOrientationEvent.requestPermission().then(state=>{
if(state==='granted')attachCompassListener();
else toast('Permissão de bússola negada');
}).catch(()=>toast('Falha ao pedir permissão'));
}else{
attachCompassListener();
}
}
function attachCompassListener(){
if(sensors.compassActive)return;
sensors.compassActive=true;
window.addEventListener('deviceorientationabsolute',onCompass);
window.addEventListener('deviceorientation',onCompass);
}
function onCompass(e){
// iOS: e.webkitCompassHeading (0-360 azimuth real)
// Android: e.alpha (0-360, mas relativo — pra absoluto, use deviceorientationabsolute)
let h=null;
if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
else if(typeof e.alpha==='number')h=360-e.alpha;
if(h===null||isNaN(h))return;
sensors.heading=h;
const c=document.getElementById('sw-compass');
if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
}
function tryStartBarometer(){
if(typeof Barometer==='undefined')return;
try{
const bar=new Barometer({frequency:1});
bar.addEventListener('reading',()=>{
sensors.pressure=bar.pressure;
sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
// tendência: diferença entre média recente e antiga
if(sensors._pressHistory.length>=10){
const half=Math.floor(sensors._pressHistory.length/2);
const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
sensors.pressureTrend=recent-old;
}
const el=document.getElementById('sw-pressure');
if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
});
bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
bar.start();
sensors.barometerActive=true;
}catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
}
async function precacheCurrentMapArea(){
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
// Pede ao usuário um centro/raio se não há mapa visível
if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
navigator.geolocation.getCurrentPosition(pos=>{
const lat=pos.coords.latitude,lng=pos.coords.longitude;
const d=0.5; // ~50km de lat/lng buffer
const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
toast('Baixando tiles…');
const channel=new MessageChannel();
channel.port1.onmessage=ev=>{
if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
};
navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
},err=>toast('GPS erro: '+err.message),{timeout:10000});
}
async function showCacheSizes(){
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
const channel=new MessageChannel();
channel.port1.onmessage=ev=>{
if(ev.data.type==='CACHE_SIZE_REPORT'){
const s=ev.data.sizes;
const info=document.getElementById('sw-cache-info');
if(info){
const total=Object.values(s).reduce((a,b)=>a+b,0);
info.innerHTML=`Cache: <strong>${total}</strong> itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
}
}
};
navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
}
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null}; const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
async function initBattery(){ async function initBattery(){

19
mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Capacitor
node_modules/
www/
android/.gradle/
android/local.properties
android/app/release/
android/app/build/
android/build/
android/.idea/
android/captures/
android/*.iml
ios/App/build/
ios/App/Pods/
ios/App/output/
# Keystore (CRÍTICO — nunca commitar chaves privadas)
*.keystore
*.jks
keystore-passwords.txt

213
mobile/README.md Normal file
View file

@ -0,0 +1,213 @@
# Shivao Mobile · Capacitor Android (e iOS futuro)
Wrapper Capacitor pra empacotar o Shivao web como app nativo Android (.apk pra sideload, .aab pra Play Store) e iOS no futuro.
**Arquitetura:** o frontend HTML mora em `app/diario-bordo.html` (1 fonte de verdade). Um script (`scripts/sync-html.mjs`) copia pra `server/public/index.html` (Express serve) e `mobile/www/index.html` (Capacitor empacota).
---
## 🛠️ Pré-requisitos (instalar 1 vez)
### Windows (recomendado pra você)
1. **JDK 17+** — Adoptium Temurin é a melhor escolha:
```
winget install EclipseAdoptium.Temurin.17.JDK
```
Verificar: `java -version` deve mostrar `17.x`
2. **Android Studio** — instala SDK + emulador + tudo:
```
winget install Google.AndroidStudio
```
Após abrir, ir em **Tools → SDK Manager** e instalar:
- Android SDK Platform 34 (Android 14)
- Android SDK Build-Tools 34.0.0
- Android SDK Command-line Tools (latest)
- Android SDK Platform-Tools
3. **Variáveis de ambiente** (Painel de Controle → Sistema → Variáveis de Ambiente):
- `JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-17.0.x` (ajuste path)
- `ANDROID_HOME=C:\Users\pontu\AppData\Local\Android\Sdk`
- Adicionar ao PATH: `%JAVA_HOME%\bin`, `%ANDROID_HOME%\platform-tools`
4. Reiniciar terminal. Verificar:
```bash
java -version
adb --version
```
**Tempo total de instalação:** ~30-45 min (download de ~3GB).
---
## 🚀 Build inicial (faz 1 vez)
```bash
cd mobile
# Instala deps Capacitor + plugins
npm install
# Sincroniza HTML do app/ pra mobile/www/
node ../scripts/sync-html.mjs
# Adiciona projeto Android (gera mobile/android/)
npx cap add android
# Sincroniza plugins nativos no projeto Android
npx cap sync android
```
### Permissões Android (editar manualmente após `cap add android`)
Abra `mobile/android/app/src/main/AndroidManifest.xml` e adicione DENTRO do `<manifest>`:
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
```
---
## 🔐 Keystore PERMANENTE (CRÍTICO — gera 1 vez, guarda pra sempre)
```bash
cd mobile/android/app
keytool -genkey -v -keystore shivao-release.keystore -alias shivao -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Karlao, O=PontualTech, L=Sao Paulo, ST=SP, C=BR"
# Senha forte (mínimo 8 chars). ANOTE em local seguro — perda = perda permanente do app na Play Store.
```
**BACKUP IMEDIATO:**
```bash
cp shivao-release.keystore C:/Users/pontu/Downloads/Shivao-keystore-backup/shivao-release.keystore
echo "Senha keystore: SUA_SENHA_AQUI" >> C:/Users/pontu/Downloads/Shivao-keystore-backup/keystore-passwords.txt
echo "Alias: shivao" >> C:/Users/pontu/Downloads/Shivao-keystore-backup/keystore-passwords.txt
```
E também faça upload em Drive privado, HD externo etc — perdeu = não publica update na Play Store nunca mais.
### Configurar signing no Gradle
Edite `mobile/android/app/build.gradle`, dentro de `android { ... }`:
```gradle
signingConfigs {
release {
storeFile file('shivao-release.keystore')
storePassword System.getenv('SHIVAO_KEYSTORE_PWD') ?: 'SUA_SENHA_AQUI'
keyAlias 'shivao'
keyPassword System.getenv('SHIVAO_KEY_PWD') ?: 'SUA_SENHA_AQUI'
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
}
}
```
---
## 📦 Build do APK (sideload) e AAB (Play Store)
```bash
cd mobile
# Sincroniza última versão do HTML antes de build
npm run sync
# APK assinado pra sideload (~3-5 min primeira vez, ~1 min em rebuilds)
npm run android:build:apk
# Output: mobile/android/app/build/outputs/apk/release/app-release.apk
# AAB pra Play Store (formato preferido)
npm run android:build:aab
# Output: mobile/android/app/build/outputs/bundle/release/app-release.aab
```
---
## 🌐 Alternativa: Build na nuvem (sem instalar nada local)
Se não quiser instalar 3GB de Android Studio:
### Opção A: Codemagic (mais simples)
- 500min grátis/mês, suficiente pra ~10 builds
- Conecta ao Forgejo, push detecta e roda
- Configura assinatura no painel
### Opção B: Container Docker no Coolify (mais self-hosted)
- Subir imagem `mingc/android-build-box` no Coolify
- Cron job ou webhook do Forgejo dispara build
- APK/AAB salvo em volume persistente
Ambas opções: vamos preparar quando você decidir.
---
## 🎮 Submeter Play Store
1. **Conta Google Play Developer:** $25 1× em https://play.google.com/console
2. Após aprovado (~24h), criar novo app:
- Nome: "Shivao - Diário de Bordo"
- Categoria: "Maps & Navigation"
- Idioma: pt-BR
3. **Subir AAB:** Production → Releases → Create new release → upload `app-release.aab`
4. **Conteúdo obrigatório:**
- Política de privacidade (URL pública — vou criar uma rota `/politica-privacidade` no servidor)
- Screenshots: mínimo 2 (vou gerar via Playwright se quiser)
- Ícone 512×512 (vou gerar do icon.svg)
- Descrição curta (80 chars) e completa
5. **Content rating:** preencher questionário (~5min)
6. **Pricing:** Free (cobrança vai por dentro do app via Asaas, não Google IAP — economia de 30%)
7. **Submit pra review:** Google revisa em 1-7 dias
---
## 📱 iOS (FASE 4 futura)
Requer:
- Mac (mini M4 ~R$5k mais barato)
- Apple Developer Program $99/ano
- Xcode
```bash
npx cap add ios
npx cap sync ios
npx cap open ios # abre Xcode
# Build Archive → Distribute → App Store Connect
```
---
## 🐛 Troubleshooting
**`adb: command not found`** — adicione `%ANDROID_HOME%\platform-tools` ao PATH
**`SDK location not found`** — crie `mobile/android/local.properties`:
```
sdk.dir=C:\\Users\\pontu\\AppData\\Local\\Android\\Sdk
```
**Build muito lento** — primeiro build baixa Gradle + dependências (~500MB). Próximos builds usam cache.
**APK muito grande** — habilitar minify em `build.gradle`:
```gradle
buildTypes.release.minifyEnabled true
buildTypes.release.shrinkResources true
```

101
mobile/android/.gitignore vendored Normal file
View file

@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
mobile/android/app/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View file

@ -0,0 +1,63 @@
apply plugin: 'com.android.application'
android {
namespace "br.com.pontualtech.shivao"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2
versionName "1.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
storeFile file('shivao-release.keystore')
storePassword 'ShivaoKeystore2026!'
keyAlias 'shivao'
keyPassword 'ShivaoKeystore2026!'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View file

@ -0,0 +1,24 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
mobile/android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
</manifest>

View file

@ -0,0 +1,5 @@
package br.com.pontualtech.shivao;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Shivao</string>
<string name="title_activity_main">Shivao</string>
<string name="package_name">br.com.pontualtech.shivao</string>
<string name="custom_url_scheme">br.com.pontualtech.shivao</string>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View file

@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View file

@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,21 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

View file

@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
mobile/android/gradlew vendored Normal file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
mobile/android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View file

@ -0,0 +1,16 @@
ext {
minSdkVersion = 22
compileSdkVersion = 34
targetSdkVersion = 34
androidxActivityVersion = '1.8.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.12.0'
androidxFragmentVersion = '1.6.2'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.9.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
}

View file

@ -0,0 +1,44 @@
{
"appId": "br.com.pontualtech.shivao",
"appName": "Shivao",
"webDir": "www",
"bundledWebRuntime": false,
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"backgroundColor": "#0e2a3dFF"
},
"ios": {
"contentInset": "automatic",
"scrollEnabled": true,
"backgroundColor": "#0e2a3dFF"
},
"server": {
"androidScheme": "https",
"iosScheme": "capacitor",
"cleartext": false,
"url": "https://shivao.pontualtech.work",
"allowNavigation": [
"shivao.pontualtech.work",
"tile.openstreetmap.org",
"*.tile.openstreetmap.org",
"api.windy.com",
"api.open-meteo.com",
"marine-api.open-meteo.com",
"fonts.googleapis.com",
"fonts.gstatic.com",
"cdnjs.cloudflare.com"
]
},
"plugins": {
"Geolocation": {
"permissions": ["location"]
},
"LocalNotifications": {
"smallIcon": "ic_stat_icon_config_sample",
"iconColor": "#a07832",
"sound": "alarme.wav"
}
}
}

1319
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
mobile/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "shivao-mobile",
"version": "1.2.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",
"scripts": {
"sync": "node ../scripts/sync-html.mjs && npx cap sync",
"android:open": "npx cap open android",
"android:build:apk": "cd android && ./gradlew assembleRelease",
"android:build:aab": "cd android && ./gradlew bundleRelease",
"ios:open": "npx cap open ios"
},
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/app": "^6.0.1",
"@capacitor/core": "^6.1.2",
"@capacitor/geolocation": "^6.0.1",
"@capacitor/local-notifications": "^6.1.0",
"@capacitor/network": "^6.0.2",
"@capacitor/preferences": "^6.0.2",
"@capacitor/status-bar": "^6.0.1"
},
"devDependencies": {
"@capacitor/cli": "^6.1.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View file

@ -0,0 +1,134 @@
# Listing Google Play — Shivao
Tudo pronto pra copiar/colar no Google Play Console.
---
## Nome do app (max 30 chars)
```
Shivao - Diário de Bordo
```
## Descrição curta (max 80 chars — aparece nos resultados de busca)
```
Diário de bordo náutico: GPS, vigia de fundeio com alarme, tempo Windy.
```
## Descrição completa (max 4000 chars — aparece na página do app)
```
⛵ SHIVAO — Diário de bordo digital pro seu veleiro
O companheiro completo de quem navega: registro de viagens, manutenções, GPS em tempo real, vigia de fundeio com alarme remoto, geofencing e previsão meteorológica Windy.
🛰 NAVEGAÇÃO E SEGURANÇA
• Registro de travessias com tripulação, datas, horímetro, vento, distância em milhas náuticas e velocidade em nós
• Rastreio GPS em tempo real com mapa Leaflet (offline pra alto-mar quando pré-cacheia a área)
• Bússola com indicação cardeal (Android com sensor) e barômetro com tendência de pressão
• Vigia de fundeio com âncora + centro de giro independente, raio editável, auto-recentro
• Alarme local de drift: som forte (Web Audio com tons alternados), vibração, tela vermelha
• Alarme remoto (com plano Pro): ao detectar deriva, dispara mensagem em todos seus canais (Telegram, ntfy, e-mail, WhatsApp via Twilio) — você sabe mesmo se o celular tá desligado
• Dead-man switch no servidor: se o app parar de mandar heartbeat enquanto fundeado, o servidor dispara alarme sozinho
• Geofencing com zonas de proibição e atenção
• Compartilhamento público: link temporário pra tripulação ver sua posição em tempo real
🔧 MANUTENÇÃO
• Registro de reparos com horímetro, custo, prestador, fotos e notas fiscais
• Lista de pendências com data prevista OU horímetro alvo
• Alertas automáticos quando manutenção está próxima
• Checklists customizáveis (segurança, motor, vela, fundeio, travessia longa)
📷 MÍDIA
• Fotos da câmera com 1 toque
• Áudio gravado direto no app
• Vídeo da câmera ou galeria
• Tudo armazenado localmente (IndexedDB) e sincronizado com sua nuvem privada
🌬 METEOROLOGIA
• Windy Point Forecast API (premium — sua chave pessoal): vento u/v, ondas, modelos GFS/ECMWF
• Open-Meteo grátis como fallback
• Modo economia de energia que ajusta GPS conforme nível de bateria
📥 IMPORT/EXPORT
• GPX (chartplotter, Navionics, Garmin, OpenCPN)
• CSV de viagens, manutenções e pendências
• Backup/restore JSON completo (com mídia em base64)
• Imprimir/PDF
☁️ NUVEM PRIVADA (com plano Pro/Captain)
• Sync entre dispositivos (celular, tablet, desktop)
• Backup automático
• Webhooks diretos: Telegram, Discord, qualquer URL custom
• Compartilhamento de posição em tempo real
🔐 PRIVACIDADE PRIMEIRO
✗ Zero analytics, zero tracking, zero ads
✗ Não vendemos seus dados
✗ Servidor próprio na Alemanha (Hetzner)
✓ LGPD/GDPR compliant
✓ Senha hash bcrypt
✓ JWT com refresh
✓ Audit log de ações sensíveis (Captain)
💰 PLANOS
• FREE — Vigia de âncora local + diário básico (10 últimos registros)
• PRO — R$19/mês ou R$149/ano: tudo offline + sync nuvem + GPS + mídia + geofencing + alarme remoto
• CAPTAIN — R$39/mês ou R$299/ano: Pro + Windy premium + multi-barco + relatórios PDF + audit log
Pagamento via PIX, cartão ou boleto (Asaas, parceiro brasileiro).
⚓ FEITO POR QUEM NAVEGA
Shivao começou como projeto pessoal pro veleiro Shivao. Hoje é a ferramenta diária de quem leva navegação a sério.
📧 Suporte: contato@pontualtech.com.br
🌐 Site: shivao.pontualtech.work
📜 Política de privacidade: shivao.pontualtech.work/politica
```
---
## Categoria
```
Maps & Navigation
```
## Tags (palavras-chave pra ASO — Google Play)
```
veleiro, navegação, náutico, gps náutico, vigia fundeio, anchor watch, diário de bordo, marítimo, sailor, marina, vento, meteorologia náutica, windy, leaflet
```
## Email de contato
```
contato@pontualtech.com.br
```
## Site
```
https://shivao.pontualtech.work
```
## URL política de privacidade
```
https://shivao.pontualtech.work/politica
```
## Classificação de conteúdo
- **IARC age rating:** todos
- **Sem violência, sexo, drogas, jogos de azar, conteúdo gerado por usuário público (compartilhamento é privado por link, não público listado)**
## Permissões a justificar (Play Store pede explicação pra cada)
| Permissão | Justificativa |
|---|---|
| `ACCESS_BACKGROUND_LOCATION` | Vigia de fundeio precisa monitorar posição do barco mesmo quando o app não está em foreground — alarme remoto se detectar drift fora do raio de fundeio. |
| `POST_NOTIFICATIONS` | Notificar usuário sobre drift de fundeio, alarmes locais e confirmações de pagamento. |
| `FOREGROUND_SERVICE_LOCATION` | Heartbeat de GPS pra dead-man switch durante vigia ativa. |
| `WAKE_LOCK` | Manter tela acesa durante vigia/alarme pra mostrar status (mãos molhadas no convés). |
| `VIBRATE` | Componente do alarme de fundeio (junto com som). |
## Screenshots (mínimo 2, recomendado 4-8)
Ver pasta `mobile/play-store-assets/screenshots/`
## Ícone (512×512 PNG)
Ver `mobile/play-store-assets/icon-512.png`
## Feature graphic (1024×500 PNG, opcional mas recomendado)
Ver `mobile/play-store-assets/feature-graphic.png`

25
scripts/sync-html.mjs Normal file
View file

@ -0,0 +1,25 @@
// Sync único do HTML do Shivão pra todos os destinos.
// Fonte de verdade: app/diario-bordo.html
// Destinos: server/public/index.html (Express) + mobile/www/index.html (Capacitor)
import { copyFile, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const src = resolve(root, 'app/diario-bordo.html');
const targets = [
resolve(root, 'server/public/index.html'),
resolve(root, 'mobile/www/index.html'),
];
async function sync() {
for (const t of targets) {
await mkdir(dirname(t), { recursive: true });
await copyFile(src, t);
console.log(`${src}${t}`);
}
}
sync().catch(e => { console.error(e); process.exit(1); });

117
server/package-lock.json generated
View file

@ -9,9 +9,11 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"zod": "^4.3.6" "zod": "^4.3.6"
@ -65,6 +67,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
@ -173,6 +184,12 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -366,6 +383,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -732,6 +758,97 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -12,9 +12,11 @@
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"zod": "^4.3.6" "zod": "^4.3.6"

View file

@ -774,6 +774,435 @@ header{
body{background:#fff;background-image:none} body{background:#fff;background-image:none}
.entry{break-inside:avoid;border:1px solid #999} .entry{break-inside:avoid;border:1px solid #999}
} }
/* ═══════════════════════════════════════════════════════
MODERN PROFESSIONAL OVERLAY · v2 — Apr 2026
Mantém identidade marítima (cores Fraunces + Manrope + JetBrains Mono),
mas moderniza profundamente: depth, microinterações, polish premium.
═══════════════════════════════════════════════════════ */
:root{
/* spacing scale (8pt base) */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
/* radius scale (sutis — alinhados ao tom marítimo) */
--r-sm:4px; --r-md:8px; --r-lg:12px; --r-xl:16px; --r-pill:9999px;
/* shadow scale (depth profissional) */
--sh-1:0 1px 2px rgba(14,42,61,.06), 0 1px 1px rgba(14,42,61,.04);
--sh-2:0 2px 4px rgba(14,42,61,.06), 0 4px 8px rgba(14,42,61,.05);
--sh-3:0 4px 8px rgba(14,42,61,.07), 0 8px 16px rgba(14,42,61,.06);
--sh-4:0 8px 16px rgba(14,42,61,.08), 0 16px 32px rgba(14,42,61,.08);
--sh-5:0 16px 32px rgba(14,42,61,.10), 0 32px 64px rgba(14,42,61,.10);
--sh-glow:0 0 0 4px rgba(160,120,50,.12);
--sh-glow-blue:0 0 0 4px rgba(31,91,118,.18);
/* transitions */
--t-fast:120ms cubic-bezier(.4,0,.2,1);
--t-base:200ms cubic-bezier(.4,0,.2,1);
--t-slow:320ms cubic-bezier(.4,0,.2,1);
/* superfícies elevadas premium */
--surface-1:#fbf5e2;
--surface-2:#ffffff;
--surface-elevated:rgba(255,255,255,.78);
--border-subtle:rgba(184,156,108,.22);
--border-strong:rgba(184,156,108,.55);
}
/* ── BACKGROUND limpo elegante (sem ruído) ── */
body{
background:
radial-gradient(ellipse 1200px 800px at 50% -200px, #f8eed5 0%, transparent 60%),
linear-gradient(180deg, #efe5cd 0%, #e7d9b6 100%);
background-attachment:fixed;
background-image:
radial-gradient(ellipse 1200px 800px at 50% -200px, #f8eed5 0%, transparent 60%),
linear-gradient(180deg, #efe5cd 0%, #e7d9b6 100%);
}
/* ── HEADER premium com glow sutil + gradient ── */
header{
background:linear-gradient(180deg, #0e2a3d 0%, #143447 100%);
box-shadow:
inset 0 1px 0 rgba(200,159,84,.30),
inset 0 -1px 0 rgba(160,120,50,.55),
0 4px 24px rgba(14,42,61,.18),
0 12px 48px rgba(14,42,61,.10);
border-bottom:none;
}
.header-row{padding:2px 0}
.boat-name{
text-shadow:0 1px 2px rgba(0,0,0,.25);
letter-spacing:-.015em;
}
.compass-mark{
filter:drop-shadow(0 2px 8px rgba(200,159,84,.35));
transition:transform var(--t-base);
}
.compass-mark:hover{transform:rotate(15deg)}
/* ── TABS modernas: pill nav com indicador animado ── */
.tabs{
background:rgba(239,229,205,.92);
backdrop-filter:blur(12px) saturate(1.2);
-webkit-backdrop-filter:blur(12px) saturate(1.2);
border-bottom:1px solid var(--border-subtle);
padding:8px 4px 0;
gap:2px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset;
}
.tab{
border-radius:var(--r-md) var(--r-md) 0 0;
padding:13px 18px 14px;
transition:color var(--t-base), background var(--t-base);
position:relative;
}
.tab:hover{
color:var(--ink-mid);
background:rgba(255,255,255,.4);
}
.tab.active{
color:var(--ink-deep);
background:linear-gradient(180deg, rgba(255,255,255,.85), rgba(255,255,255,.5));
}
.tab.active::after{
height:3px;left:14px;right:14px;bottom:0;
background:linear-gradient(90deg, var(--brass), var(--brass-bright));
border-radius:2px 2px 0 0;
box-shadow:0 0 8px rgba(160,120,50,.4);
}
/* ── CARDS com depth real (sombra multi-layer) ── */
.gps-card,.anchor-card,.export-card,.empty,.entry,.contact-card{
border-radius:var(--r-lg);
box-shadow:var(--sh-2);
transition:box-shadow var(--t-base), transform var(--t-base);
overflow:hidden;
}
.entry{
border-radius:var(--r-md);
box-shadow:var(--sh-1);
border-color:var(--border-subtle);
}
.entry:hover{box-shadow:var(--sh-2)}
.gps-card,.anchor-card{
border-radius:var(--r-lg);
background:linear-gradient(165deg, #0e2a3d 0%, #1a3d54 100%);
border:1px solid rgba(200,159,84,.4);
box-shadow:
inset 0 1px 0 rgba(200,159,84,.2),
var(--sh-3),
0 0 32px rgba(31,91,118,.15);
}
.gps-card::before,.anchor-card::before{display:none}
.gps-card.idle{
background:linear-gradient(165deg, #fbf5e2 0%, #f3e7c4 100%);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-2);
}
.gps-stats,.anchor-stats-bar{
background:rgba(0,0,0,.15);
padding:14px;
border-radius:var(--r-md);
margin:12px 0 14px;
}
.gps-card.idle .gps-stats{background:rgba(184,156,108,.08)}
.export-card,.empty{
background:var(--surface-elevated);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border-radius:var(--r-lg);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-2);
}
.export-card:hover{box-shadow:var(--sh-3)}
/* ── BUTTONS premium com lift e ripple ── */
.btn{
border-radius:var(--r-md);
transition:all var(--t-base);
font-weight:600;
letter-spacing:.12em;
position:relative;
overflow:hidden;
}
.btn:hover{
transform:translateY(-1px);
box-shadow:var(--sh-2);
}
.btn:active{transform:translateY(0); box-shadow:var(--sh-1)}
.btn:focus-visible{
outline:none;
box-shadow:var(--sh-glow);
}
.btn-primary{
background:linear-gradient(180deg, #143a52 0%, #0e2a3d 100%);
border-color:rgba(0,0,0,.4);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.10),
var(--sh-1);
}
.btn-primary:hover{
background:linear-gradient(180deg, #1a4a66 0%, #143a52 100%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.15),
var(--sh-3);
}
.btn-brass{
background:linear-gradient(180deg, #b88a3c 0%, #8d6826 100%);
border-color:rgba(0,0,0,.25);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.18),
var(--sh-1);
}
.btn-brass:hover{
background:linear-gradient(180deg, #c89954 0%, #a07832 100%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.25),
var(--sh-3);
}
.btn-danger{
background:transparent;
border:1px solid var(--storm);
color:var(--storm);
}
.btn-danger:hover{
background:linear-gradient(180deg, #a04545 0%, #8c3434 100%);
color:#fff;
border-color:transparent;
}
/* ── FIELDS modernos com focus ring ── */
.field input,.field textarea,.field select{
border-radius:var(--r-md);
background:var(--surface-2);
border:1px solid var(--border-subtle);
padding:12px 14px;
font-size:15px;
transition:border-color var(--t-base), box-shadow var(--t-base), background var(--t-base);
}
.field input:hover,.field textarea:hover,.field select:hover{
border-color:var(--border-strong);
}
.field input:focus,.field textarea:focus,.field select:focus{
border-color:var(--brass);
background:#fff;
box-shadow:var(--sh-glow);
}
.field-label{font-size:11px; font-weight:600; color:var(--ink-mid); margin-bottom:6px}
/* ── MODAL com depth maior + entrance polida ── */
.modal-backdrop{
background:rgba(14,42,61,.65);
backdrop-filter:blur(8px) saturate(1.1);
-webkit-backdrop-filter:blur(8px) saturate(1.1);
}
.modal{
border-radius:var(--r-xl) var(--r-xl) 0 0;
border-top:none;
box-shadow:
0 -4px 12px rgba(0,0,0,.05),
0 -16px 48px rgba(14,42,61,.25);
background-image:none;
background:linear-gradient(180deg, #fcf6e4 0%, #f8f0d4 100%);
}
@media(min-width:600px){
.modal{
border-radius:var(--r-xl);
box-shadow:0 24px 64px rgba(14,42,61,.30), 0 8px 16px rgba(14,42,61,.10);
}
}
.modal-head{
background:transparent;
padding:18px 22px;
border-bottom:1px solid var(--border-subtle);
}
.modal-body{padding:22px}
.modal-foot{
background:rgba(0,0,0,.02);
padding:16px 22px;
}
/* ── FAB redesenhado: floating elegante ── */
.fab{
border-radius:var(--r-pill) !important;
background:linear-gradient(135deg, #b88a3c 0%, #8d6826 100%) !important;
box-shadow:
0 4px 12px rgba(160,120,50,.4),
0 12px 32px rgba(14,42,61,.15),
inset 0 1px 0 rgba(255,255,255,.25) !important;
transition:transform var(--t-base), box-shadow var(--t-base);
}
.fab:hover{
transform:translateY(-2px) scale(1.05);
box-shadow:
0 8px 20px rgba(160,120,50,.5),
0 16px 40px rgba(14,42,61,.20),
inset 0 1px 0 rgba(255,255,255,.30);
}
.fab:active{transform:translateY(0) scale(1)}
/* ── STATS GRID polido ── */
.stats{
border-radius:var(--r-lg);
background:transparent;
border:none;
gap:10px;
box-shadow:none;
display:grid;
}
.stat{
border-radius:var(--r-md);
background:var(--surface-elevated);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border:1px solid var(--border-subtle);
box-shadow:var(--sh-1);
padding:16px 16px 14px;
transition:transform var(--t-base), box-shadow var(--t-base);
}
.stat:hover{transform:translateY(-2px); box-shadow:var(--sh-2)}
.stat::before{
height:3px; border-radius:2px;
background:linear-gradient(90deg, var(--brass), var(--brass-bright));
transform:scaleX(.5);
transition:transform var(--t-base);
}
.stat:hover::before{transform:scaleX(1)}
/* ── EMPTY STATE elegante ── */
.empty{padding:56px 28px; border-radius:var(--r-lg)}
.empty::before,.empty::after{display:none}
.empty-rose{
width:64px; height:64px;
filter:drop-shadow(0 4px 12px rgba(160,120,50,.25));
margin-bottom:18px;
}
.empty-title{font-size:21px}
.empty-text{font-size:14px; line-height:1.65; opacity:.85}
/* ── STATUS PILLS arredondados ── */
.status-pill{
border-radius:var(--r-pill);
padding:3px 10px;
font-size:9.5px;
letter-spacing:.16em;
}
/* ── ALERTS com sombra sutil ── */
.alert{
border-radius:var(--r-md);
border:1px solid var(--border-subtle);
border-left-width:4px;
box-shadow:var(--sh-1);
padding:14px 16px;
transition:transform var(--t-base);
}
.alert:hover{transform:translateX(2px)}
/* ── PASSENGER PILLS modernos ── */
.pax-pill,.pax-tag,.channel-pill{
border-radius:var(--r-pill);
padding:4px 12px;
}
/* ── ICON BUTTONS polidos ── */
.icon-btn{
border-radius:var(--r-md);
transition:all var(--t-base);
}
.icon-btn:hover{
background:rgba(184,156,108,.15);
transform:scale(1.08);
}
/* ── MEDIA THUMBS ── */
.media-thumb,.media-item{
border-radius:var(--r-md);
transition:transform var(--t-base), box-shadow var(--t-base);
}
.media-thumb:hover,.media-item:hover{
transform:scale(1.04);
box-shadow:var(--sh-2);
z-index:2;
}
/* ── SENSOR WIDGET premium (top-right) ── */
#sensors-widget{
border-radius:var(--r-lg) !important;
background:linear-gradient(165deg, rgba(14,42,61,.92), rgba(20,58,82,.92)) !important;
backdrop-filter:blur(20px) saturate(1.4);
-webkit-backdrop-filter:blur(20px) saturate(1.4);
box-shadow:
0 4px 12px rgba(0,0,0,.15),
0 8px 24px rgba(14,42,61,.18),
inset 0 1px 0 rgba(200,159,84,.2) !important;
border:1px solid rgba(200,159,84,.18);
transition:transform var(--t-base), box-shadow var(--t-base);
}
#sensors-widget:hover{
transform:translateY(-1px);
box-shadow:
0 8px 16px rgba(0,0,0,.20),
0 16px 32px rgba(14,42,61,.22);
}
/* ── AUTH BOX polido ── */
#auth-box{
background:linear-gradient(165deg, rgba(255,255,255,.5), rgba(255,255,255,.2));
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
border-radius:var(--r-md);
padding:14px;
margin-top:14px !important;
border:1px solid var(--border-subtle);
border-top:1px solid var(--border-subtle) !important;
}
/* ── CURSOR pointer em interativos críticos ── */
.tab,.btn,.fab,.icon-btn,.media-btn,.media-thumb,.media-item,.alert,
.entry-actions button,.modal-head button,.pax-tag button{
cursor:pointer;
}
/* ── FOCUS visible global ── */
*:focus-visible{
outline:2px solid var(--brass);
outline-offset:2px;
border-radius:var(--r-sm);
}
/* ── REDUCED MOTION (accessibility) ── */
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{
animation-duration:.01ms !important;
animation-iteration-count:1 !important;
transition-duration:.01ms !important;
}
}
/* ── LARGE SCREENS: mais respiro ── */
@media(min-width:780px){
.container{padding:32px 24px 96px}
.stats{grid-template-columns:repeat(4,1fr)}
}
/* ── SCROLLBAR sutil (Firefox + WebKit) ── */
*{scrollbar-width:thin; scrollbar-color:var(--brass) transparent}
::-webkit-scrollbar{width:8px; height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(160,120,50,.35); border-radius:var(--r-pill)}
::-webkit-scrollbar-thumb:hover{background:rgba(160,120,50,.55)}
/* ── TYPOGRAPHY refinement ── */
.boat-name{font-size:26px}
.entry-title{font-size:20px; line-height:1.25}
.modal-head h3{font-size:20px}
.empty-title{margin-bottom:6px}
/* ── SECTION HEADER sutilmente refinado ── */
.section-header h2{font-size:11px; font-weight:600; color:var(--ink-mid)}
/* ── TOOLBAR refinada ── */
.toolbar{gap:10px; margin-bottom:18px}
</style> </style>
</head> </head>
<body> <body>
@ -899,6 +1328,7 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button> <button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button>
</div> </div>
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div> <div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div> </div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))"> <div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div> <div class="export-card-title">📡 Compartilhar posição em tempo real</div>
@ -1289,7 +1719,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div> <div class="zone-toast" id="zone-toast"></div>
<script> <script>
const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}}; const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const STORAGE_KEY='diario_bordo_v3'; const STORAGE_KEY='diario_bordo_v3';
const TRACKING_KEY='diario_tracking_v3'; const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3'; const ANCHOR_KEY='diario_anchor_v3';
@ -1437,7 +1867,7 @@ function renderGPSBanner(){
function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})} function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()} function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
function openModal(id){document.getElementById(id).classList.add('show')} function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')} function closeModal(id){document.getElementById(id).classList.remove('show')}
@ -1821,6 +2251,8 @@ async function updateStorageInfo(){
loadTrackingState(); loadTrackingState();
loadAnchorState(); loadAnchorState();
initBattery(); initBattery();
initServiceWorker();
initSensorWidget();
// tenta auto-fetch do tempo após pequeno delay // tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000); setTimeout(maybeAutoFetchWeather,3000);
})(); })();
@ -2356,11 +2788,198 @@ function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
async function cloudFetch(path,opts={}){ async function cloudFetch(path,opts={}){
if(!cloudConfigured())throw new Error('Nuvem não configurada'); if(!cloudConfigured())throw new Error('Nuvem não configurada');
const r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${state.cloud.token}`,'Content-Type':'application/json',...(opts.headers||{})}}); // Usa JWT se logado (multi-tenant SaaS), senão BOAT_TOKEN legado (single-tenant pessoal)
if(!r.ok)throw new Error(`HTTP ${r.status}`); const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
const doFetch=()=>fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth}`,'Content-Type':'application/json',...(opts.headers||{})}});
let r=await doFetch();
// 401 com JWT? Tenta refresh + retry 1×
if(r.status===401&&state.auth&&state.auth.refreshToken){
const ok=await authRefresh();
if(ok){const auth2=state.auth.accessToken;r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth2}`,'Content-Type':'application/json',...(opts.headers||{})}})}
}
if(!r.ok){let detail='';try{const j=await r.clone().json();detail=j.error||JSON.stringify(j.issues||{})}catch{}throw new Error(`HTTP ${r.status}${detail?' · '+detail:''}`)}
return r; return r;
} }
// ===== Auth (multi-tenant SaaS — Login/Signup) =====
async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/signup'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password,name})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authLogin(email,password){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authRefresh(){
if(!state.auth||!state.auth.refreshToken)return false;
try{
const r=await fetch(cloudUrl('/api/auth/refresh'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refreshToken:state.auth.refreshToken})});
if(!r.ok){state.auth=null;saveState();return false}
const j=await r.json();
state.auth.accessToken=j.accessToken;
saveState();
return true;
}catch{return false}
}
function authLogout(){
state.auth=null;
state.license=null;
saveState();
renderAuthBox();
toast('Sessão encerrada');
}
async function refreshLicense(){
try{
const r=await cloudFetch('/api/license');
state.license=await r.json();
saveState();
}catch(e){console.warn('license fetch:',e.message)}
}
function renderAuthBox(){
const box=document.getElementById('auth-box');
if(!box)return;
if(state.auth&&state.auth.user){
const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="openUpgradeModal()">⚡ Fazer upgrade pra Pro</button>':''}`;
}else{
box.innerHTML=`
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-sm" id="auth-tab-login" onclick="document.getElementById('auth-form-login').style.display='block';document.getElementById('auth-form-signup').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-signup').classList.remove('btn-brass')" style="flex:1">Entrar</button>
<button class="btn btn-sm" id="auth-tab-signup" onclick="document.getElementById('auth-form-signup').style.display='block';document.getElementById('auth-form-login').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-login').classList.remove('btn-brass')" style="flex:1">Cadastrar</button>
</div>
<div id="auth-form-login">
<div class="field"><label class="field-label">Email</label><input type="email" id="login-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Senha</label><input type="password" id="login-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthLoginClick()">Entrar</button>
</div>
<div id="auth-form-signup" style="display:none">
<div class="field"><label class="field-label">Email</label><input type="email" id="signup-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Nome (opcional)</label><input type="text" id="signup-name" placeholder="Seu nome"></div>
<div class="field"><label class="field-label">Senha (mín 8 chars)</label><input type="password" id="signup-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthSignupClick()">Criar conta</button>
</div>
<div id="auth-msg" style="margin-top:8px;font-size:11px;color:var(--storm)"></div>`;
document.getElementById('auth-tab-login').classList.add('btn-brass');
}
}
async function onAuthLoginClick(){
const e=document.getElementById('login-email').value.trim();
const p=document.getElementById('login-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
}
// ===== Upgrade modal (Asaas billing) =====
let _upgradeChosen={plan:'pro',cycle:'yearly',billingType:'PIX'};
async function openUpgradeModal(){
if(!state.auth){toast('Faça login primeiro');return}
// Cria modal dinamicamente (não precisa adicionar HTML no body)
const existing=document.getElementById('upgrade-modal');
if(existing)existing.remove();
const m=document.createElement('div');
m.id='upgrade-modal';
m.className='modal-backdrop';
m.style.cssText='align-items:center;justify-content:center;display:flex;z-index:9999';
m.innerHTML=`<div class="modal" style="max-width:420px;padding:24px"><h3 style="margin:0 0 12px;font-family:var(--f-display),Georgia,serif;font-style:italic;color:var(--brass)">Upgrade · escolha seu plano</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:12px 0">
<button id="up-pro" class="btn" onclick="_upgradeChoose('pro')" style="padding:10px;text-align:left"><strong>Pro</strong><br><small style="opacity:.8">R\$19/mês ou R\$149/ano</small></button>
<button id="up-captain" class="btn" onclick="_upgradeChoose('captain')" style="padding:10px;text-align:left"><strong>Captain</strong><br><small style="opacity:.8">R\$39/mês ou R\$299/ano</small></button>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button id="up-monthly" class="btn btn-sm" onclick="_upgradeCycle('monthly')" style="flex:1">Mensal</button>
<button id="up-yearly" class="btn btn-sm" onclick="_upgradeCycle('yearly')" style="flex:1">Anual (-35%)</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<button id="up-pix" class="btn btn-sm" onclick="_upgradeType('PIX')" style="flex:1">PIX (instantâneo)</button>
<button id="up-cc" class="btn btn-sm" onclick="_upgradeType('CREDIT_CARD')" style="flex:1">Cartão</button>
<button id="up-bol" class="btn btn-sm" onclick="_upgradeType('BOLETO')" style="flex:1">Boleto</button>
</div>
<div id="up-summary" style="font-family:var(--f-mono);font-size:12px;color:var(--ink);background:var(--bg-aged);padding:10px;border-radius:4px;margin-bottom:12px"></div>
<button class="btn btn-block btn-primary" onclick="_doCheckout()">Continuar pra pagamento</button>
<button class="btn btn-block" onclick="document.getElementById('upgrade-modal').remove()" style="margin-top:6px">Cancelar</button>
<div id="up-result" style="margin-top:14px"></div>
</div>`;
document.body.appendChild(m);
_upgradeRefresh();
}
function _upgradeChoose(p){_upgradeChosen.plan=p;_upgradeRefresh()}
function _upgradeCycle(c){_upgradeChosen.cycle=c;_upgradeRefresh()}
function _upgradeType(t){_upgradeChosen.billingType=t;_upgradeRefresh()}
function _upgradeRefresh(){
const c=_upgradeChosen;
['up-pro','up-captain'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
['up-monthly','up-yearly'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
['up-pix','up-cc','up-bol'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
document.getElementById('up-'+c.plan)?.classList.add('btn-brass');
document.getElementById('up-'+c.cycle)?.classList.add('btn-brass');
document.getElementById('up-'+(c.billingType==='PIX'?'pix':c.billingType==='CREDIT_CARD'?'cc':'bol'))?.classList.add('btn-brass');
const prices={pro:{monthly:19,yearly:149},captain:{monthly:39,yearly:299}};
const v=prices[c.plan][c.cycle];
document.getElementById('up-summary').textContent=`Plano ${c.plan==='pro'?'Pro':'Captain'} · ${c.cycle==='monthly'?'mensal':'anual'} · R$ ${v.toFixed(2)} via ${c.billingType==='PIX'?'PIX':c.billingType==='CREDIT_CARD'?'Cartão':'Boleto'}`;
}
async function _doCheckout(){
const out=document.getElementById('up-result');out.innerHTML='<em>Criando cobrança…</em>';
try{
const r=await cloudFetch('/api/billing/checkout',{method:'POST',body:JSON.stringify(_upgradeChosen)});
const data=await r.json();
let html='';
if(data.pix&&data.pix.qrCode){
html=`<div style="text-align:center"><strong>Escaneie o QR Code PIX:</strong><br><img src="data:image/png;base64,${data.pix.qrCode}" style="max-width:240px;margin:10px auto;border:4px solid #fff;border-radius:8px"><br><div style="font-family:var(--f-mono);font-size:10px;word-break:break-all;background:var(--bg-aged);padding:6px;border-radius:4px;margin:6px 0"><strong>Copia e cola:</strong><br>${data.pix.payload||''}</div><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${data.pix.payload||''}').then(()=>toast('PIX copiado!'))">Copiar PIX</button></div>`;
}else if(data.invoiceUrl){
html=`<a href="${data.invoiceUrl}" target="_blank" class="btn btn-block btn-primary">Abrir página de pagamento ↗</a>`;
}
html+=`<div style="margin-top:10px;font-size:11px;opacity:.8">Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.</div>`;
html+=`<button class="btn btn-block" style="margin-top:10px" onclick="checkPaymentStatus('${data.paymentId}')">Verificar pagamento</button>`;
out.innerHTML=html;
}catch(e){out.innerHTML='<span style="color:var(--storm)">Erro: '+e.message+'</span>'}
}
async function checkPaymentStatus(paymentId){
try{
const r=await cloudFetch('/api/billing/payment/'+paymentId);
const p=await r.json();
if(['RECEIVED','CONFIRMED','RECEIVED_IN_CASH'].includes(p.status)){
toast('Pago! Licença ativada 🎉');
await refreshLicense();renderAuthBox();
document.getElementById('upgrade-modal')?.remove();
}else{
toast('Status: '+p.status);
}
}catch(e){toast('Erro: '+e.message)}
}
async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim();
const p=document.getElementById('signup-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(p.length<8){msg.textContent='Senha precisa de no mínimo 8 caracteres';return}
try{await authSignup(e,p,n);msg.style.color='var(--algae)';msg.textContent='Conta criada e logado!';toast('Conta criada')}catch(err){msg.textContent=err.message}
}
function bindCloudInputs(){ function bindCloudInputs(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token'); const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
if(!u||!t)return; if(!u||!t)return;
@ -2951,6 +3570,216 @@ async function testWindyKey(){
} }
} }
// ===== Capacitor adapter (detecta nativo + usa APIs nativas com fallback Web) =====
const isNative=()=>!!(window.Capacitor&&window.Capacitor.isNativePlatform&&window.Capacitor.isNativePlatform());
const nativePlatform=()=>(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'web';
// Geolocation: Capacitor.Geolocation (background-capable) > navigator.geolocation
async function nativeWatchPosition(onUpdate,onError,opts){
if(isNative()&&window.Capacitor.Plugins.Geolocation){
try{
const{Geolocation}=window.Capacitor.Plugins;
// Pede permission upfront
const perm=await Geolocation.requestPermissions({permissions:['location']}).catch(()=>({location:'denied'}));
if(perm.location==='denied'){onError({code:1,PERMISSION_DENIED:1,message:'Permissão negada'});return null}
const id=await Geolocation.watchPosition({enableHighAccuracy:!!opts?.enableHighAccuracy,timeout:opts?.timeout||15000,maximumAge:opts?.maximumAge||0},(pos,err)=>{
if(err){onError({code:err.code||3,message:err.message||'GPS erro',PERMISSION_DENIED:1,POSITION_UNAVAILABLE:2,TIMEOUT:3});return}
onUpdate({coords:{latitude:pos.coords.latitude,longitude:pos.coords.longitude,accuracy:pos.coords.accuracy,speed:pos.coords.speed},timestamp:pos.timestamp});
});
return{native:true,id};
}catch(e){console.warn('[native gps] fallback to web:',e.message)}
}
// Fallback web
const id=navigator.geolocation.watchPosition(onUpdate,onError,opts);
return{native:false,id};
}
async function nativeClearWatch(handle){
if(!handle)return;
if(handle.native&&isNative()){try{await window.Capacitor.Plugins.Geolocation.clearWatch({id:handle.id})}catch(e){}}
else if(handle.id!=null)navigator.geolocation.clearWatch(handle.id);
}
// Local notifications: nativo no Android, fallback toast no web
async function nativeNotify(title,body,id){
if(isNative()&&window.Capacitor.Plugins.LocalNotifications){
try{
const{LocalNotifications}=window.Capacitor.Plugins;
await LocalNotifications.requestPermissions().catch(()=>{});
await LocalNotifications.schedule({notifications:[{id:id||Date.now()%2147483647,title,body,sound:null,smallIcon:'ic_stat_icon_config_sample'}]});
return;
}catch(e){console.warn('[native notify]',e.message)}
}
toast(title+(body?': '+body:''));
}
// ===== Service Worker (offline real — só registra no Web, no Capacitor o WebView usa cache nativo) =====
function initServiceWorker(){
if(isNative())return; // Capacitor não usa SW (tem cache próprio + APIs nativas)
if(!('serviceWorker' in navigator))return;
navigator.serviceWorker.register('/sw.js').then(reg=>{
console.log('[SW] registered:',reg.scope);
}).catch(e=>console.warn('[SW] failed:',e.message));
}
// ===== Sensores: Bússola + Barômetro + Status Offline =====
const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
function initSensorWidget(){
// Cria widget flutuante (canto superior direito, abaixo do header)
const w=document.createElement('div');
w.id='sensors-widget';
w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
w.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer" id="sw-toggle"><span id="sw-online" style="color:#3f7768"></span><span id="sw-compass">🧭 ---°</span><span id="sw-pressure" style="opacity:.7">🌡 ---</span><span style="opacity:.5;font-size:10px"></span></div><div id="sw-extra" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid rgba(160,120,50,.3);font-size:10px;line-height:1.5"></div>';
document.body.appendChild(w);
document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
// Online/offline status
updateOnlineStatus();
window.addEventListener('online',updateOnlineStatus);
window.addEventListener('offline',updateOnlineStatus);
// Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
tryStartCompass();
tryStartBarometer();
}
function toggleSensorPanel(){
const ex=document.getElementById('sw-extra');
const open=ex.style.display==='none';
ex.style.display=open?'block':'none';
if(open)renderSensorPanel();
}
function renderSensorPanel(){
const ex=document.getElementById('sw-extra');
const online=navigator.onLine;
const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?' caindo':' estável');
ex.innerHTML=`
<div>Conexão: <strong>${online?'online':'offline'}</strong></div>
<div>Bússola: <strong>${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}</strong></div>
<div>Pressão: <strong>${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}</strong></div>
<div>Tendência: <strong>${trend}</strong></div>
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
${sensors.heading===null?'<button onclick="requestCompassPermission()" style="background:#a07832;color:#0e2a3d;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Ativar bússola</button>':''}
<button onclick="precacheCurrentMapArea()" style="background:#3f7768;color:#efe5cd;border:none;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Pré-cachear mapa</button>
<button onclick="showCacheSizes()" style="background:#0e2a3d;color:#efe5cd;border:1px solid #a07832;padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer">Cache</button>
</div>
<div id="sw-cache-info" style="margin-top:6px;font-size:9px;opacity:.7"></div>`;
}
function updateOnlineStatus(){
const dot=document.getElementById('sw-online');
if(!dot)return;
dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
dot.title=navigator.onLine?'Online':'Offline — usando cache';
}
function headingToCardinal(deg){
const dirs=['N','NE','L','SE','S','SO','O','NO'];
return dirs[Math.round(deg/45)%8];
}
function tryStartCompass(){
// iOS Safari requer permission via gesture do usuário (DeviceOrientationEvent.requestPermission)
// Android Chrome: funciona direto sem permission
if(typeof DeviceOrientationEvent==='undefined')return;
if(typeof DeviceOrientationEvent.requestPermission==='function'){
// iOS — aguarda usuário clicar "Ativar bússola"
return;
}
// Android: pode iniciar direto
attachCompassListener();
}
function requestCompassPermission(){
if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
if(typeof DeviceOrientationEvent.requestPermission==='function'){
DeviceOrientationEvent.requestPermission().then(state=>{
if(state==='granted')attachCompassListener();
else toast('Permissão de bússola negada');
}).catch(()=>toast('Falha ao pedir permissão'));
}else{
attachCompassListener();
}
}
function attachCompassListener(){
if(sensors.compassActive)return;
sensors.compassActive=true;
window.addEventListener('deviceorientationabsolute',onCompass);
window.addEventListener('deviceorientation',onCompass);
}
function onCompass(e){
// iOS: e.webkitCompassHeading (0-360 azimuth real)
// Android: e.alpha (0-360, mas relativo — pra absoluto, use deviceorientationabsolute)
let h=null;
if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
else if(typeof e.alpha==='number')h=360-e.alpha;
if(h===null||isNaN(h))return;
sensors.heading=h;
const c=document.getElementById('sw-compass');
if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
}
function tryStartBarometer(){
if(typeof Barometer==='undefined')return;
try{
const bar=new Barometer({frequency:1});
bar.addEventListener('reading',()=>{
sensors.pressure=bar.pressure;
sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
// tendência: diferença entre média recente e antiga
if(sensors._pressHistory.length>=10){
const half=Math.floor(sensors._pressHistory.length/2);
const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
sensors.pressureTrend=recent-old;
}
const el=document.getElementById('sw-pressure');
if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
});
bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
bar.start();
sensors.barometerActive=true;
}catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
}
async function precacheCurrentMapArea(){
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
// Pede ao usuário um centro/raio se não há mapa visível
if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
navigator.geolocation.getCurrentPosition(pos=>{
const lat=pos.coords.latitude,lng=pos.coords.longitude;
const d=0.5; // ~50km de lat/lng buffer
const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
toast('Baixando tiles…');
const channel=new MessageChannel();
channel.port1.onmessage=ev=>{
if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
};
navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
},err=>toast('GPS erro: '+err.message),{timeout:10000});
}
async function showCacheSizes(){
if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
const channel=new MessageChannel();
channel.port1.onmessage=ev=>{
if(ev.data.type==='CACHE_SIZE_REPORT'){
const s=ev.data.sizes;
const info=document.getElementById('sw-cache-info');
if(info){
const total=Object.values(s).reduce((a,b)=>a+b,0);
info.innerHTML=`Cache: <strong>${total}</strong> itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
}
}
};
navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
}
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null}; const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
async function initBattery(){ async function initBattery(){

196
server/public/sw.js Normal file
View file

@ -0,0 +1,196 @@
// 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';
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}`;
// Recursos do "shell" (UI essencial). Pré-cacheados no install.
const SHELL_URLS = [
'/',
'/manifest.json',
'/icon.svg',
// Leaflet (cdnjs)
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then(async (cache) => {
// Tenta cachear cada um individualmente — falha de 1 não derruba install
await Promise.all(SHELL_URLS.map(url =>
cache.add(url).catch(e => console.warn('[SW] precache miss:', url, e.message))
));
}).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
// Remove caches antigos do shell e windy (não tiles — que tem própria versão)
keys.filter(k => k.startsWith('shivao-shell-') && k !== SHELL_CACHE)
.concat(keys.filter(k => k.startsWith('shivao-windy-') && k !== WINDY_CACHE))
.map(k => caches.delete(k))
)).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET') return; // POST/PUT/DELETE passam direto
// /api do Shivão: passa direto, não interferir em sync/heartbeat/auth
if (url.pathname.startsWith('/api/')) return;
// Map tiles (OSM): cache-first, com network fallback
if (url.host === 'tile.openstreetmap.org' || url.pathname.match(/\.(png|jpg|jpeg)$/) && url.pathname.match(/\/\d+\/\d+\/\d+\./)) {
event.respondWith(cacheFirst(event.request, TILES_CACHE));
return;
}
// Windy API: network-first, fallback cache (mostra última previsão quando offline)
if (url.host === 'api.windy.com') {
event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000)); // TTL 1h
return;
}
// Open-Meteo (fallback meteo): mesmo padrão
if (url.host === 'api.open-meteo.com' || url.host === 'marine-api.open-meteo.com') {
event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000));
return;
}
// Fonts Google (fontes carregam no startup): cache-first
if (url.host === 'fonts.googleapis.com' || url.host === 'fonts.gstatic.com') {
event.respondWith(cacheFirst(event.request, SHELL_CACHE));
return;
}
// CDN Leaflet e similares
if (url.host === 'cdnjs.cloudflare.com') {
event.respondWith(cacheFirst(event.request, SHELL_CACHE));
return;
}
// Mesma origem (HTML/JS/CSS do app): stale-while-revalidate
if (url.origin === self.location.origin) {
event.respondWith(staleWhileRevalidate(event.request, SHELL_CACHE));
return;
}
// Resto: deixa rede tratar
});
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
try {
const fresh = await fetch(request);
if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
return fresh;
} catch (e) {
return new Response('offline-no-cache', { status: 503, statusText: 'Offline' });
}
}
async function networkFirst(request, cacheName, ttlMs) {
const cache = await caches.open(cacheName);
try {
const fresh = await fetch(request);
if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
return fresh;
} catch (e) {
const cached = await cache.match(request);
if (cached) return cached;
throw e;
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(res => {
if (res.ok) cache.put(request, res.clone()).catch(() => {});
return res;
}).catch(() => cached || new Response('offline', { status: 503 }));
return cached || fetchPromise;
}
// Mensagens da página: pré-cachear tiles de uma área
self.addEventListener('message', (event) => {
if (!event.data || !event.data.type) return;
if (event.data.type === 'PRECACHE_TILES') {
const { bounds, minZoom, maxZoom } = event.data;
precacheTiles(bounds, minZoom, maxZoom).then(stats => {
event.source && event.source.postMessage({ type: 'PRECACHE_DONE', ...stats });
}).catch(err => {
event.source && event.source.postMessage({ type: 'PRECACHE_ERROR', error: err.message });
});
}
if (event.data.type === 'CACHE_SIZE') {
cacheSizes().then(sizes => {
event.source && event.source.postMessage({ type: 'CACHE_SIZE_REPORT', sizes });
});
}
if (event.data.type === 'CLEAR_TILES') {
caches.delete(TILES_CACHE).then(ok => {
event.source && event.source.postMessage({ type: 'TILES_CLEARED', ok });
});
}
});
// Calcula tiles necessários e baixa em paralelo (max 6 simultâneos)
async function precacheTiles(bounds, minZoom, maxZoom) {
const cache = await caches.open(TILES_CACHE);
const tiles = [];
for (let z = minZoom; z <= maxZoom; z++) {
const min = lngLatToTile(bounds.west, bounds.north, z);
const max = lngLatToTile(bounds.east, bounds.south, z);
for (let x = min.x; x <= max.x; x++) {
for (let y = min.y; y <= max.y; y++) {
tiles.push({ z, x, y });
}
}
}
let done = 0, failed = 0;
const total = tiles.length;
// Limita a 6 conexões paralelas pra respeitar OSM tile policy
const queue = tiles.slice();
await Promise.all([...Array(6)].map(async () => {
while (queue.length) {
const t = queue.shift();
const url = `https://tile.openstreetmap.org/${t.z}/${t.x}/${t.y}.png`;
try {
const res = await fetch(url, { headers: { 'User-Agent': 'Shivao-PWA' } });
if (res.ok) await cache.put(url, res);
else failed++;
} catch { failed++; }
done++;
}
}));
return { total, done, failed };
}
function lngLatToTile(lng, lat, z) {
const x = Math.floor((lng + 180) / 360 * Math.pow(2, z));
const y = Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z));
return { x, y };
}
async function cacheSizes() {
const result = {};
for (const name of [SHELL_CACHE, TILES_CACHE, WINDY_CACHE]) {
const cache = await caches.open(name);
const keys = await cache.keys();
result[name] = keys.length;
}
return result;
}

65
server/src/auth.js Normal file
View file

@ -0,0 +1,65 @@
// Auth — JWT + bcrypt pra Shivao SaaS multi-tenant.
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || (process.env.BOAT_TOKEN + '-jwt-derive-key');
const JWT_ACCESS_TTL = '7d'; // access token expira em 7 dias (menos fricção pra usuário móvel)
const JWT_REFRESH_TTL = '90d'; // refresh em 90 dias
if (!process.env.JWT_SECRET && !process.env.BOAT_TOKEN) {
console.warn('[auth] AVISO: JWT_SECRET não configurado. Use env JWT_SECRET em produção.');
}
export async function hashPassword(plain) {
if (!plain || plain.length < 8) throw new Error('Senha precisa ter no mínimo 8 caracteres');
return bcrypt.hash(plain, 10);
}
export async function verifyPassword(plain, hash) {
if (!plain || !hash) return false;
try { return await bcrypt.compare(plain, hash); } catch { return false; }
}
export function signAccessToken(user) {
return jwt.sign({ uid: user.id, email: user.email, type: 'access' }, JWT_SECRET, { expiresIn: JWT_ACCESS_TTL });
}
export function signRefreshToken(user) {
return jwt.sign({ uid: user.id, type: 'refresh' }, JWT_SECRET, { expiresIn: JWT_REFRESH_TTL });
}
export function verifyToken(token) {
try { return jwt.verify(token, JWT_SECRET); } catch { return null; }
}
// Plans → features matrix
// free: âncora local + diário básico (até 10 viagens)
// pro: tudo do free + sync nuvem ilimitada + GPS tracking + mídia + geofencing
// captain: tudo do pro + Windy premium + multi-barco + relatórios PDF + audit log
export const PLANS = {
free: {
name: 'Free (Âncora)',
price_brl: 0,
features: ['anchor_local', 'diary_limited_10', 'export_gpx_basic']
},
pro: {
name: 'Pro',
price_brl_monthly: 19,
price_brl_yearly: 149,
features: ['anchor_local', 'anchor_remote', 'diary_unlimited', 'cloud_sync', 'gps_tracking', 'media_cloud', 'geofencing', 'webhooks', 'export_all']
},
captain: {
name: 'Captain',
price_brl_monthly: 39,
price_brl_yearly: 299,
features: ['anchor_local', 'anchor_remote', 'diary_unlimited', 'cloud_sync', 'gps_tracking', 'media_cloud', 'geofencing', 'webhooks', 'export_all', 'windy_premium', 'multi_boat', 'pdf_reports', 'audit_log']
}
};
export function planFeatures(plan) {
return (PLANS[plan] || PLANS.free).features;
}
export function planHasFeature(plan, feature) {
return planFeatures(plan).includes(feature);
}

119
server/src/billing.js Normal file
View file

@ -0,0 +1,119 @@
// Billing — Asaas (PIX/Boleto/Cartão) pra licenciar Pro/Captain
// Docs: https://docs.asaas.com/reference/
const ASAAS_URL = process.env.ASAAS_API_URL || 'https://api.asaas.com/v3';
const ASAAS_KEY = process.env.ASAAS_API_KEY;
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN; // shared secret pro webhook
export function isAsaasConfigured() {
return !!ASAAS_KEY;
}
async function asaasRequest(path, method = 'GET', body = null) {
if (!ASAAS_KEY) throw new Error('Asaas não configurado (env ASAAS_API_KEY ausente)');
const url = ASAAS_URL.replace(/\/$/, '') + path;
const r = await fetch(url, {
method,
headers: {
'access_token': ASAAS_KEY,
'Content-Type': 'application/json',
'User-Agent': 'Shivao-SaaS/1.0',
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let json;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!r.ok) {
const msg = json.errors?.[0]?.description || json.message || `HTTP ${r.status}`;
throw new Error(`Asaas: ${msg}`);
}
return json;
}
// Cria customer (idempotente — Asaas dedup por externalReference)
export async function getOrCreateCustomer(user) {
// Busca por externalReference primeiro
try {
const search = await asaasRequest(`/customers?externalReference=user_${user.id}`);
if (search.data && search.data.length > 0) return search.data[0].id;
} catch (e) { /* ignore search errors, fallback create */ }
const created = await asaasRequest('/customers', 'POST', {
name: user.name || user.email.split('@')[0],
email: user.email,
externalReference: `user_${user.id}`,
notificationDisabled: false,
});
return created.id;
}
// Cria cobrança avulsa (1 pagamento). PIX/CREDIT_CARD/BOLETO.
// dueDays: até quantos dias da hoje vence (PIX padrão 3 dias, boleto 7)
export async function createPayment({ customerId, plan, cycle, value, billingType, description, dueDays = 3 }) {
const due = new Date();
due.setDate(due.getDate() + dueDays);
const dueStr = due.toISOString().slice(0, 10); // YYYY-MM-DD
const body = {
customer: customerId,
billingType, // 'PIX' | 'CREDIT_CARD' | 'BOLETO' | 'UNDEFINED' (deixa cliente escolher)
value,
dueDate: dueStr,
description: description || `Shivao ${plan} (${cycle})`,
externalReference: `plan_${plan}_${cycle}_${Date.now()}`,
};
return asaasRequest('/payments', 'POST', body);
}
// Pega QR Code PIX do payment (se billingType=PIX)
export async function getPixQrCode(paymentId) {
return asaasRequest(`/payments/${paymentId}/pixQrCode`);
}
// Verifica status atual do payment (pra reconciliação se webhook falhar)
export async function getPaymentStatus(paymentId) {
return asaasRequest(`/payments/${paymentId}`);
}
// Mapeia status Asaas → ações na licença
// PENDING/AWAITING_RISK_ANALYSIS: aguardar
// CONFIRMED: pré-confirmado (cartão capturado, ainda não creditado)
// RECEIVED: dinheiro caiu, ATIVAR licença
// OVERDUE/REFUNDED/RECEIVED_IN_CASH/CHARGEBACK_REQUESTED/REFUND_REQUESTED: handle apropriado
export function isPaidStatus(status) {
return ['RECEIVED', 'CONFIRMED', 'RECEIVED_IN_CASH'].includes(status);
}
export function isFailedStatus(status) {
return ['REFUNDED', 'CHARGEBACK_REQUESTED', 'CHARGEBACK_DISPUTE', 'AWAITING_CHARGEBACK_REVERSAL'].includes(status);
}
// Validação básica de webhook: se ASAAS_WEBHOOK_TOKEN setado, verifica header.
// Asaas v3 envia o token no header `asaas-access-token`.
export function verifyWebhookToken(headerToken) {
if (!ASAAS_WEBHOOK_TOKEN) {
console.warn('[billing] ASAAS_WEBHOOK_TOKEN não configurado — webhook aceita sem verificação');
return true;
}
return headerToken === ASAAS_WEBHOOK_TOKEN;
}
// Calcula expiração baseada em ciclo + plano
export function computeExpiresAt(cycle) {
const now = Date.now();
const oneDay = 24 * 3600 * 1000;
if (cycle === 'monthly') return now + 30 * oneDay;
if (cycle === 'yearly') return now + 365 * oneDay;
return now + 30 * oneDay; // default mensal
}
// Preço por plano + ciclo
import { PLANS } from './auth.js';
export function priceFor(plan, cycle) {
const p = PLANS[plan];
if (!p) throw new Error('Plano inválido');
if (cycle === 'monthly') return p.price_brl_monthly;
if (cycle === 'yearly') return p.price_brl_yearly;
throw new Error('Ciclo inválido');
}

View file

@ -11,26 +11,58 @@ db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL'); db.pragma('synchronous = NORMAL');
db.exec(` db.exec(`
-- ===== Users + Licenses (multi-tenant SaaS) =====
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login INTEGER
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
started_at INTEGER NOT NULL,
expires_at INTEGER,
asaas_subscription_id TEXT,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_licenses_user ON licenses(user_id);
-- ===== Tabelas de dados (originalmente single-tenant, agora com user_id) =====
CREATE TABLE IF NOT EXISTS state ( CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
data TEXT NOT NULL, data TEXT NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL,
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
parent_id TEXT, parent_id TEXT,
parent_type TEXT, parent_type TEXT,
kind TEXT NOT NULL, kind TEXT NOT NULL,
mime TEXT NOT NULL, mime TEXT NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
filename TEXT NOT NULL, filename TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_media_parent ON media(parent_id); CREATE INDEX IF NOT EXISTS idx_media_parent ON media(parent_id);
CREATE TABLE IF NOT EXISTS anchor_session ( CREATE TABLE IF NOT EXISTS anchor_session (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
active INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 0,
boat_name TEXT, boat_name TEXT,
anchor_lat REAL, anchor_lat REAL,
@ -41,25 +73,31 @@ db.exec(`
last_lat REAL, last_lat REAL,
last_lng REAL, last_lng REAL,
last_distance REAL, last_distance REAL,
alarm_fired INTEGER DEFAULT 0 alarm_fired INTEGER DEFAULT 0,
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS alarm_log ( CREATE TABLE IF NOT EXISTS alarm_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
ts INTEGER NOT NULL, ts INTEGER NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
payload TEXT, payload TEXT,
sent TEXT, sent TEXT,
failed TEXT failed TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS shares ( CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
boat_name TEXT, boat_name TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
revoked INTEGER DEFAULT 0, revoked INTEGER DEFAULT 0,
zones TEXT zones TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at); CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
@ -73,6 +111,26 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS idx_sp_token_ts ON share_positions(token, ts DESC); CREATE INDEX IF NOT EXISTS idx_sp_token_ts ON share_positions(token, ts DESC);
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
asaas_payment_id TEXT UNIQUE,
asaas_customer_id TEXT,
plan TEXT NOT NULL,
cycle TEXT NOT NULL,
value REAL NOT NULL,
billing_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
invoice_url TEXT,
due_date INTEGER NOT NULL,
paid_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id);
CREATE INDEX IF NOT EXISTS idx_payments_asaas ON payments(asaas_payment_id);
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, ts INTEGER NOT NULL,
@ -93,96 +151,219 @@ try {
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
// ---- State (whole app data as a single JSON blob) ---- // ===== Migração multi-tenant =====
export function getState() { // 1) Tabelas que mudaram PRIMARY KEY (state, anchor_session): se schema antigo detectado,
const row = db.prepare('SELECT data, updated_at FROM state WHERE id = 1').get(); // recriar limpo. Sem perda de dados crítica neste momento (deploy inicial).
function recreateIfLegacy(table, newSchema) {
try {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (cols.length === 0) return; // tabela ainda não existe (CREATE TABLE pegou o schema novo)
const hasUserId = cols.some(c => c.name === 'user_id');
const idCol = cols.find(c => c.name === 'id');
// Schema antigo: id é PRIMARY KEY mas NÃO é AUTOINCREMENT (rowid alias com CHECK constraint)
const isLegacyPK = idCol && idCol.pk === 1 && !cols.some(c => c.type === 'INTEGER' && c.pk && c.name === 'id' && (c.dflt_value || '').toString().includes('AUTO'));
if (!hasUserId || isLegacyPK) {
console.log(`[migration] recreating ${table} (legacy schema detected)`);
db.exec(`DROP TABLE IF EXISTS ${table}; ${newSchema}`);
}
} catch (e) { console.warn(`[migration] recreate ${table}:`, e.message); }
}
recreateIfLegacy('state', `
CREATE TABLE state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
recreateIfLegacy('anchor_session', `
CREATE TABLE anchor_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
active INTEGER NOT NULL DEFAULT 0,
boat_name TEXT,
anchor_lat REAL,
anchor_lng REAL,
radius INTEGER,
started_at INTEGER,
last_heartbeat INTEGER,
last_lat REAL,
last_lng REAL,
last_distance REAL,
alarm_fired INTEGER DEFAULT 0,
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
// 2) Tabelas que só ganharam coluna user_id: ALTER TABLE ADD COLUMN
function ensureUserIdColumn(table) {
try {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (cols.length === 0) return;
if (!cols.some(c => c.name === 'user_id')) {
db.exec(`ALTER TABLE ${table} ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`);
console.log(`[migration] added user_id to ${table}`);
}
} catch (e) { console.warn(`[migration] ${table}:`, e.message); }
}
['media', 'alarm_log', 'shares', 'audit_log'].forEach(ensureUserIdColumn);
// 3) Índices em user_id rodam DEPOIS do ALTER TABLE (senão "no such column")
try {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id);
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
`);
} catch (e) { console.warn('[migration] user_id indexes:', e.message); }
// Garante user default (id=1, Karlão) — donos de dados pré-multi-tenant
function ensureDefaultUser() {
const existing = db.prepare('SELECT id FROM users WHERE id = 1').get();
if (existing) return;
// Senha temporária — Karlão troca via /api/auth/me PATCH ou via UI quando logar pela 1ª vez
// bcryptjs hash de 'ChangeMe2026!' com cost 10
const placeholderHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'; // 'changeme'
const now = Date.now();
db.prepare(`INSERT INTO users (id, email, password_hash, name, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?)`)
.run('karlao@outlook.com', placeholderHash, 'Karlão (default)', now, now);
// Licença Captain (todas features) pro user default — gratuita pra sempre, é o dono do servidor
db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, updated_at) VALUES (1, 'captain', 'active', ?, NULL, ?)`)
.run(now, now);
console.log('[migration] default user (id=1, karlao@outlook.com) created with captain plan');
}
ensureDefaultUser();
// ===== Multi-tenant helpers (Users + Licenses) =====
export function createUser(email, passwordHash, name) {
const now = Date.now();
const info = db.prepare('INSERT INTO users (email, password_hash, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
.run(email.toLowerCase().trim(), passwordHash, name || null, now, now);
// Toda conta nova começa free
db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, updated_at) VALUES (?, 'free', 'active', ?, NULL, ?)`)
.run(info.lastInsertRowid, now, now);
return info.lastInsertRowid;
}
export function findUserByEmail(email) {
return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim());
}
export function findUserById(id) {
return db.prepare('SELECT id, email, name, created_at, last_login FROM users WHERE id = ?').get(id);
}
export function updateLastLogin(id) {
db.prepare('UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?').run(Date.now(), Date.now(), id);
}
export function getActiveLicense(userId) {
// Pega licença mais recente ativa (se expires_at NULL ou no futuro)
const now = Date.now();
return db.prepare(`SELECT * FROM licenses WHERE user_id = ? AND status = 'active' AND (expires_at IS NULL OR expires_at > ?) ORDER BY started_at DESC LIMIT 1`).get(userId, now);
}
export function setLicense(userId, plan, expiresAt, asaasSubId) {
const now = Date.now();
// Desativa licenças anteriores
db.prepare(`UPDATE licenses SET status = 'replaced', updated_at = ? WHERE user_id = ? AND status = 'active'`).run(now, userId);
db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, asaas_subscription_id, updated_at) VALUES (?, ?, 'active', ?, ?, ?, ?)`)
.run(userId, plan, now, expiresAt || null, asaasSubId || null, now);
}
// ---- State (per-user JSON blob) ----
export function getState(userId) {
const row = db.prepare('SELECT data, updated_at FROM state WHERE user_id = ?').get(userId);
if (!row) return { data: null, updated_at: 0 }; if (!row) return { data: null, updated_at: 0 };
return { data: JSON.parse(row.data), updated_at: row.updated_at }; return { data: JSON.parse(row.data), updated_at: row.updated_at };
} }
export function setState(data) { export function setState(userId, data) {
const json = JSON.stringify(data); const json = JSON.stringify(data);
const now = Date.now(); const now = Date.now();
db.prepare(` db.prepare(`
INSERT INTO state (id, data, updated_at) VALUES (1, ?, ?) INSERT INTO state (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(json, now); `).run(userId, json, now);
return now; return now;
} }
// ---- Media metadata ---- // ---- Media metadata (per-user) ----
export function insertMedia(m) { export function insertMedia(userId, m) {
db.prepare(` db.prepare(`
INSERT INTO media (id, parent_id, parent_type, kind, mime, size, filename, created_at) INSERT INTO media (id, user_id, parent_id, parent_type, kind, mime, size, filename, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(m.id, m.parent_id || null, m.parent_type || null, m.kind, m.mime, m.size, m.filename, m.created_at || Date.now()); `).run(m.id, userId, m.parent_id || null, m.parent_type || null, m.kind, m.mime, m.size, m.filename, m.created_at || Date.now());
} }
export function listMedia() { export function listMedia(userId) {
return db.prepare('SELECT * FROM media ORDER BY created_at DESC').all(); return db.prepare('SELECT * FROM media WHERE user_id = ? ORDER BY created_at DESC').all(userId);
} }
export function getMedia(id) { export function getMedia(userId, id) {
return db.prepare('SELECT * FROM media WHERE id = ?').get(id); return db.prepare('SELECT * FROM media WHERE user_id = ? AND id = ?').get(userId, id);
} }
export function deleteMedia(id) { export function deleteMedia(userId, id) {
return db.prepare('DELETE FROM media WHERE id = ?').run(id); return db.prepare('DELETE FROM media WHERE user_id = ? AND id = ?').run(userId, id);
} }
// ---- Anchor session ---- // ---- Anchor session (per-user) ----
export function getAnchor() { export function getAnchor(userId) {
return db.prepare('SELECT * FROM anchor_session WHERE id = 1').get(); return db.prepare('SELECT * FROM anchor_session WHERE user_id = ?').get(userId);
} }
export function setAnchor(a) { export function setAnchor(userId, a) {
const cur = getAnchor(); const cur = getAnchor(userId);
if (cur) { if (cur) {
db.prepare(`UPDATE anchor_session SET active=?, boat_name=?, anchor_lat=?, anchor_lng=?, radius=?, started_at=?, last_heartbeat=?, last_lat=?, last_lng=?, last_distance=?, alarm_fired=? WHERE id=1`) db.prepare(`UPDATE anchor_session SET active=?, boat_name=?, anchor_lat=?, anchor_lng=?, radius=?, started_at=?, last_heartbeat=?, last_lat=?, last_lng=?, last_distance=?, alarm_fired=? WHERE user_id=?`)
.run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0); .run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0, userId);
} else { } else {
db.prepare(`INSERT INTO anchor_session (id, active, boat_name, anchor_lat, anchor_lng, radius, started_at, last_heartbeat, last_lat, last_lng, last_distance, alarm_fired) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) db.prepare(`INSERT INTO anchor_session (user_id, active, boat_name, anchor_lat, anchor_lng, radius, started_at, last_heartbeat, last_lat, last_lng, last_distance, alarm_fired) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0); .run(userId, a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0);
} }
} }
export function clearAnchor() { export function clearAnchor(userId) {
db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE id=1').run(); db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE user_id=?').run(userId);
} }
export function updateHeartbeat(lat, lng, dist) { export function updateHeartbeat(userId, lat, lng, dist) {
db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE id=1') db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE user_id=?')
.run(Date.now(), lat, lng, dist); .run(Date.now(), lat, lng, dist, userId);
} }
export function setAlarmFired(fired) { export function setAlarmFired(userId, fired) {
db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE id=1').run(fired ? 1 : 0); db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE user_id=?').run(fired ? 1 : 0, userId);
}
// Pra dead-man switch (busca todos anchor sessions ativos pra checar)
export function listActiveAnchors() {
return db.prepare('SELECT * FROM anchor_session WHERE active = 1').all();
} }
// ---- Alarm log ---- // ---- Alarm log (per-user) ----
export function logAlarm(type, payload, sent, failed) { export function logAlarm(userId, type, payload, sent, failed) {
db.prepare('INSERT INTO alarm_log (ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO alarm_log (user_id, ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?, ?)')
.run(Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || [])); .run(userId, Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || []));
} }
export function recentAlarms(limit = 50) { export function recentAlarms(userId, limit = 50) {
return db.prepare('SELECT * FROM alarm_log ORDER BY ts DESC LIMIT ?').all(limit); return db.prepare('SELECT * FROM alarm_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
} }
// ---- Shares ---- // ---- Shares (per-user) ----
export function createShare(token, boatName, expiresAt, zones) { export function createShare(userId, token, boatName, expiresAt, zones) {
db.prepare('INSERT INTO shares (token, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO shares (token, user_id, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?, ?)')
.run(token, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null); .run(token, userId, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null);
} }
export function updateShareZones(token, zones) { export function updateShareZones(userId, token, zones) {
db.prepare('UPDATE shares SET zones = ? WHERE token = ?').run(zones ? JSON.stringify(zones) : null, token); // Garante que share pertence ao user (não permite editar share alheio)
db.prepare('UPDATE shares SET zones = ? WHERE token = ? AND user_id = ?').run(zones ? JSON.stringify(zones) : null, token, userId);
} }
export function getShare(token) { export function getShare(token) {
// Público — não filtra por user (qualquer um com o token vê)
return db.prepare('SELECT * FROM shares WHERE token = ?').get(token); return db.prepare('SELECT * FROM shares WHERE token = ?').get(token);
} }
export function listActiveShares() { export function listActiveShares(userId) {
const now = Date.now(); const now = Date.now();
return db.prepare('SELECT * FROM shares WHERE revoked = 0 AND expires_at > ? ORDER BY created_at DESC').all(now); return db.prepare('SELECT * FROM shares WHERE user_id = ? AND revoked = 0 AND expires_at > ? ORDER BY created_at DESC').all(userId, now);
} }
export function revokeShare(token) { export function revokeShare(userId, token) {
return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ?').run(token); return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ? AND user_id = ?').run(token, userId);
} }
export function addSharePosition(token, lat, lng, speed) { export function addSharePosition(token, lat, lng, speed) {
db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)')
.run(token, lat, lng, speed || 0, Date.now()); .run(token, lat, lng, speed || 0, Date.now());
// mantém apenas últimas 500 posições por share
db.prepare(`DELETE FROM share_positions WHERE token = ? AND id NOT IN (SELECT id FROM share_positions WHERE token = ? ORDER BY ts DESC LIMIT 500)`).run(token, token); db.prepare(`DELETE FROM share_positions WHERE token = ? AND id NOT IN (SELECT id FROM share_positions WHERE token = ? ORDER BY ts DESC LIMIT 500)`).run(token, token);
} }
export function getSharePositions(token, limit = 500) { export function getSharePositions(token, limit = 500) {
@ -200,13 +381,48 @@ export function cleanupExpiredShares() {
return toDelete.length; return toDelete.length;
} }
// ---- Audit log (ações sensíveis: who/what/when para investigação de incidentes) ---- // ---- Payments (Asaas) ----
export function audit(action, entity, entityId, summary, ip) { export function createPayment(p) {
db.prepare('INSERT INTO audit_log (ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?)') const now = Date.now();
.run(Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null); const info = db.prepare(`INSERT INTO payments (user_id, asaas_payment_id, asaas_customer_id, plan, cycle, value, billing_type, status, invoice_url, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(p.user_id, p.asaas_payment_id || null, p.asaas_customer_id || null, p.plan, p.cycle, p.value, p.billing_type, p.status || 'PENDING', p.invoice_url || null, p.due_date, now, now);
return info.lastInsertRowid;
} }
export function recentAudit(limit = 100) { export function findPaymentByAsaasId(asaasId) {
return db.prepare('SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?').all(limit); return db.prepare('SELECT * FROM payments WHERE asaas_payment_id = ?').get(asaasId);
}
export function updatePaymentStatus(asaasId, status, paidAt) {
db.prepare('UPDATE payments SET status = ?, paid_at = ?, updated_at = ? WHERE asaas_payment_id = ?')
.run(status, paidAt || null, Date.now(), asaasId);
}
export function listUserPayments(userId, limit = 50) {
return db.prepare('SELECT * FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
}
export function setUserAsaasCustomerId(userId, customerId) {
// Cache de mapeamento user → asaas customer pra reaproveitar em pagamentos futuros
// Guardado no campo do user (vou adicionar coluna se não existir)
try {
const cols = db.prepare("PRAGMA table_info(users)").all();
if (!cols.some(c => c.name === 'asaas_customer_id')) {
db.exec('ALTER TABLE users ADD COLUMN asaas_customer_id TEXT');
}
db.prepare('UPDATE users SET asaas_customer_id = ? WHERE id = ?').run(customerId, userId);
} catch (e) { console.warn('[db] setAsaasCustomerId:', e.message); }
}
export function getUserAsaasCustomerId(userId) {
try {
const row = db.prepare('SELECT asaas_customer_id FROM users WHERE id = ?').get(userId);
return row?.asaas_customer_id || null;
} catch { return null; }
}
// ---- Audit log (per-user) ----
export function audit(userId, action, entity, entityId, summary, ip) {
db.prepare('INSERT INTO audit_log (user_id, ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(userId, Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null);
}
export function recentAudit(userId, limit = 100) {
return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
} }
export const dataDir = DATA_DIR; export const dataDir = DATA_DIR;

View file

@ -6,7 +6,9 @@ import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import * as db from './db.js'; import * as db from './db.js';
import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js'; import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js';
import { validate, setStateSchema, updateZonesSchema } from './schemas/index.js'; import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema, checkoutSchema } from './schemas/index.js';
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
import * as billing from './billing.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000'); const PORT = parseInt(process.env.PORT || '3000');
@ -44,17 +46,209 @@ const publicShareLimiter = rateLimit({
message: { error: 'Too many requests, slow down.' }, message: { error: 'Too many requests, slow down.' },
}); });
// Auth middleware // Auth middleware: aceita JWT (multi-tenant) OU BOAT_TOKEN legado (mapeia ao user 1)
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const auth = req.headers.authorization || ''; const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (token !== TOKEN) return res.status(401).json({ error: 'Unauthorized' }); if (!token) return res.status(401).json({ error: 'Missing token' });
// Fallback BOAT_TOKEN: backwards-compat com app legado do dono (Karlão), mapeia pro user default id=1
if (token === TOKEN) {
req.user = { id: 1, email: 'karlao@outlook.com', viaBoatToken: true };
return next();
}
// JWT
const payload = verifyToken(token);
if (!payload || payload.type !== 'access' || !payload.uid) {
return res.status(401).json({ error: 'Invalid token' });
}
// Carrega user fresh do DB pra confirmar que ainda existe
const user = db.findUserById(payload.uid);
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
next(); next();
} }
// ==== Public endpoints ==== // ==== Public endpoints ====
app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() })); app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
// ===== Auth endpoints (multi-tenant SaaS) =====
app.post('/api/auth/signup', validate(signupSchema), async (req, res) => {
const { email, password, name } = req.body;
if (db.findUserByEmail(email)) return res.status(409).json({ error: 'Email já cadastrado' });
try {
const hash = await hashPassword(password);
const id = db.createUser(email, hash, name);
db.audit(id, 'user_signup', 'user', String(id), { email }, req.ip);
const user = db.findUserById(id);
db.updateLastLogin(id);
res.json({
user,
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user),
});
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post('/api/auth/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = db.findUserByEmail(email);
if (!user) return res.status(401).json({ error: 'Credenciais inválidas' });
const ok = await verifyPassword(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Credenciais inválidas' });
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
res.json({
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
});
});
app.post('/api/auth/refresh', (req, res) => {
const { refreshToken } = req.body || {};
if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' });
const payload = verifyToken(refreshToken);
if (!payload || payload.type !== 'refresh') return res.status(401).json({ error: 'Invalid refresh' });
const user = db.findUserById(payload.uid);
if (!user) return res.status(401).json({ error: 'User not found' });
res.json({ accessToken: signAccessToken(user) });
});
app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Plans + license info
app.get('/api/license', requireAuth, (req, res) => {
const lic = db.getActiveLicense(req.user.id) || { plan: 'free', status: 'active', expires_at: null };
res.json({
plan: lic.plan,
status: lic.status,
expires_at: lic.expires_at,
features: planFeatures(lic.plan),
plans: PLANS,
billingEnabled: billing.isAsaasConfigured(),
});
});
// ===== Billing endpoints (Asaas) =====
app.get('/api/billing/status', (req, res) => {
res.json({ configured: billing.isAsaasConfigured() });
});
app.post('/api/billing/checkout', requireAuth, validate(checkoutSchema), async (req, res) => {
if (!billing.isAsaasConfigured()) return res.status(503).json({ error: 'Billing não configurado no servidor (ASAAS_API_KEY ausente)' });
if (req.user.viaBoatToken) return res.status(403).json({ error: 'Use uma conta com login (não BOAT_TOKEN) pra fazer upgrade' });
const { plan, cycle, billingType } = req.body;
try {
const value = billing.priceFor(plan, cycle);
let customerId = db.getUserAsaasCustomerId(req.user.id);
if (!customerId) {
customerId = await billing.getOrCreateCustomer(req.user);
db.setUserAsaasCustomerId(req.user.id, customerId);
}
const payment = await billing.createPayment({
customerId, plan, cycle, value, billingType,
description: `Shivao ${PLANS[plan].name} (${cycle === 'monthly' ? 'mensal' : 'anual'})`,
});
db.createPayment({
user_id: req.user.id,
asaas_payment_id: payment.id,
asaas_customer_id: customerId,
plan, cycle, value, billing_type: billingType,
status: payment.status,
invoice_url: payment.invoiceUrl,
due_date: new Date(payment.dueDate).getTime(),
});
db.audit(req.user.id, 'checkout_created', 'payment', payment.id, { plan, cycle, value, billingType }, req.ip);
// Pra PIX, busca QR code
let pix = null;
if (billingType === 'PIX') {
try { pix = await billing.getPixQrCode(payment.id); } catch (e) { console.warn('[pix qrcode]', e.message); }
}
res.json({
paymentId: payment.id,
invoiceUrl: payment.invoiceUrl,
bankSlipUrl: payment.bankSlipUrl,
status: payment.status,
value: payment.value,
dueDate: payment.dueDate,
pix: pix ? { qrCode: pix.encodedImage, payload: pix.payload, expiresAt: pix.expirationDate } : null,
});
} catch (e) {
console.error('[checkout]', e);
res.status(500).json({ error: e.message });
}
});
app.get('/api/billing/payment/:id', requireAuth, async (req, res) => {
const local = db.findPaymentByAsaasId(req.params.id);
if (!local || local.user_id !== req.user.id) return res.status(404).json({ error: 'not found' });
// Reconcilia com Asaas se ainda PENDING (pra caso webhook ter falhado)
if (local.status === 'PENDING' && billing.isAsaasConfigured()) {
try {
const fresh = await billing.getPaymentStatus(req.params.id);
if (fresh.status !== local.status) {
db.updatePaymentStatus(req.params.id, fresh.status, billing.isPaidStatus(fresh.status) ? Date.now() : null);
if (billing.isPaidStatus(fresh.status)) {
db.setLicense(local.user_id, local.plan, billing.computeExpiresAt(local.cycle), local.asaas_payment_id);
db.audit(local.user_id, 'license_activated', 'payment', req.params.id, { plan: local.plan, cycle: local.cycle, source: 'reconcile' }, req.ip);
}
}
return res.json({ ...local, status: fresh.status });
} catch (e) { /* fallthrough returns cached */ }
}
res.json(local);
});
app.get('/api/billing/payments', requireAuth, (req, res) => {
res.json(db.listUserPayments(req.user.id));
});
// Webhook Asaas: chama isso quando status muda. Precisa ser configurado no painel Asaas → Integrações
// URL: https://shivao.pontualtech.work/api/billing/asaas-webhook
// Header asaas-access-token: ASAAS_WEBHOOK_TOKEN (defina o mesmo no Coolify env)
app.post('/api/billing/asaas-webhook', (req, res) => {
const headerToken = req.headers['asaas-access-token'];
if (!billing.verifyWebhookToken(headerToken)) {
console.warn('[asaas-webhook] invalid token');
return res.status(401).json({ error: 'Invalid webhook token' });
}
const event = req.body;
if (!event || !event.event || !event.payment) {
return res.status(400).json({ error: 'Invalid payload' });
}
const p = event.payment;
const local = db.findPaymentByAsaasId(p.id);
if (!local) {
console.warn('[asaas-webhook] payment not found:', p.id);
// Aceitar 200 mesmo assim — Asaas não retentar
return res.json({ ok: true, ignored: true });
}
db.updatePaymentStatus(p.id, p.status, billing.isPaidStatus(p.status) ? Date.now() : null);
// Ativar licença se pago
if (billing.isPaidStatus(p.status)) {
db.setLicense(local.user_id, local.plan, billing.computeExpiresAt(local.cycle), p.id);
db.audit(local.user_id, 'license_activated', 'payment', p.id, { plan: local.plan, cycle: local.cycle, event: event.event }, req.ip);
console.log(`[asaas-webhook] license activated user=${local.user_id} plan=${local.plan}`);
} else if (billing.isFailedStatus(p.status)) {
// Pagamento estornado: revogar se a licença vinculada a esse payment
const lic = db.getActiveLicense(local.user_id);
if (lic && lic.asaas_subscription_id === p.id) {
db.setLicense(local.user_id, 'free', null, null);
db.audit(local.user_id, 'license_revoked', 'payment', p.id, { reason: p.status }, req.ip);
}
}
res.json({ ok: true });
});
// Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome // Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome
app.get('/.well-known/assetlinks.json', (req, res) => { app.get('/.well-known/assetlinks.json', (req, res) => {
res.json([{ res.json([{
@ -69,6 +263,180 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
}]); }]);
}); });
// Termos de Uso
app.get('/termos', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Termos de Uso · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}.warn{background:#8c343422;border-left:4px solid #8c3434;padding:12px 16px;margin:20px 0}a{color:#3f7768}small{opacity:.7}</style></head><body>
<h1>Termos de Uso Shivao</h1>
<p><small>Última atualização: 27 de abril de 2026 · Versão 1.0</small></p>
<p>Ao usar o aplicativo <strong>Shivao</strong> (operado por <strong>PontualTech</strong>, CNPJ 32.772.178/0001-47), você concorda com estes termos. Leia com atenção.</p>
<h2>1. Aceitação</h2>
<p>O uso do app implica aceitação completa destes termos. Se não concorda, não use.</p>
<h2>2. Cadastro e conta</h2>
<ul>
<li>Você precisa ter 18 anos OU autorização do responsável legal.</li>
<li>Informações verdadeiras e atualizadas. Senha é responsabilidade sua.</li>
<li>1 conta por usuário. Compartilhamento de credenciais é proibido.</li>
<li>Podemos suspender contas com violação destes termos.</li>
</ul>
<h2>3. Planos, pagamentos e renovação</h2>
<ul>
<li><strong>Free:</strong> grátis, recursos limitados (vigia local + diário 10 últimas).</li>
<li><strong>Pro/Captain:</strong> assinatura mensal ou anual via Asaas (PIX, cartão, boleto).</li>
<li><strong>Renovação:</strong> ao fim do ciclo, você renova manualmente. Sem cobrança automática surpresa.</li>
<li><strong>Reembolso:</strong> 7 dias de arrependimento (CDC art. 49) devolução integral via mesmo método. Após 7 dias: pro-rata do tempo restante.</li>
</ul>
<h2>4. Uso permitido</h2>
<ul>
<li>Uso pessoal ou profissional náutico (lazer, trabalho, charters).</li>
<li>1 usuário = 1 ou múltiplos barcos (no plano Captain).</li>
<li>Compartilhamento público de posição é OK pra tripulação/familia (links temporários).</li>
</ul>
<h2>5. Uso PROIBIDO</h2>
<ul>
<li> Engenharia reversa do app ou backend (exceto pra interoperar legalmente).</li>
<li> Revender o serviço como white-label sem licença comercial.</li>
<li> Atacar a infraestrutura (DDoS, brute-force, exploit).</li>
<li> Cadastrar bots ou contas falsas em massa.</li>
<li> Usar pra atividade ilegal (pesca em área proibida, navegação clandestina, etc).</li>
</ul>
<h2>6. Limitação de responsabilidade IMPORTANTE</h2>
<div class="warn"><strong> AVISO CRÍTICO PARA NAVEGAÇÃO:</strong>
<p>O Shivao é uma <strong>FERRAMENTA AUXILIAR</strong> de navegação e segurança. <strong>NÃO substitui</strong>:</p>
<ul>
<li>Equipamentos náuticos certificados (chartplotter, AIS, VHF, balsas).</li>
<li>Cartas náuticas oficiais (Marinha do Brasil, NOAA).</li>
<li>Atenção do skipper.</li>
<li>Comunicação com a Capitania dos Portos.</li>
</ul>
<p>Não nos responsabilizamos por:</p>
<ul>
<li>Decisões tomadas com base no app (rota, fundeio, meteorologia).</li>
<li>Falha de GPS, internet, sensores ou notificações.</li>
<li>Danos materiais, pessoais ou ambientais decorrentes do uso.</li>
<li>Perda de dados (faça backups regulares).</li>
</ul>
<p><strong>O comandante da embarcação é o único responsável pela segurança a bordo.</strong></p></div>
<h2>7. Propriedade intelectual</h2>
<ul>
<li>Código, logo, nome "Shivao" pertencem à PontualTech.</li>
<li>Seus dados (viagens, mídia) pertencem a VOCÊ exporte quando quiser, exclua a qualquer momento.</li>
<li>Bibliotecas open source: Leaflet (BSD-2), OpenStreetMap (ODbL), express-rate-limit (MIT), bcryptjs (MIT), jsonwebtoken (MIT).</li>
</ul>
<h2>8. Suspensão e cancelamento</h2>
<ul>
<li>Você pode cancelar a qualquer momento via app (Aba Conta Sair Excluir conta).</li>
<li>Podemos suspender se houver violação destes termos, com aviso por e-mail (exceto urgências de segurança).</li>
<li>Após cancelamento: 30 dias pra reativar, depois exclusão permanente.</li>
</ul>
<h2>9. Mudanças nos termos</h2>
<p>Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual em <a href="https://shivao.pontualtech.work/termos">shivao.pontualtech.work/termos</a>.</p>
<h2>10. Lei aplicável e foro</h2>
<p>Estes termos seguem a lei brasileira. Foro: comarca de São Paulo/SP. Disputas de consumo podem usar <a href="https://www.consumidor.gov.br">consumidor.gov.br</a> antes de judicializar.</p>
<h2>11. Contato</h2>
<p>Suporte: <a href="mailto:contato@pontualtech.com.br">contato@pontualtech.com.br</a><br>
Privacidade/LGPD: <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a></p>
<hr style="margin:40px 0;border:none;border-top:1px solid #a07832">
<p style="text-align:center"><small>Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}</small></p>
</body></html>`);
});
// Política de Privacidade (URL pública obrigatória pra Play Store + LGPD)
app.get('/politica', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Política de Privacidade · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}code{background:#fff;padding:2px 6px;border-radius:3px;font-size:.9em}a{color:#3f7768}small{opacity:.7}</style></head><body>
<h1>Política de Privacidade Shivao</h1>
<p><small>Última atualização: 27 de abril de 2026 · Versão 1.0</small></p>
<p>O <strong>Shivao</strong> é um aplicativo de diário de bordo náutico operado por <strong>PontualTech</strong> (CNPJ 32.772.178/0001-47, São Paulo/SP, Brasil). Esta política descreve como coletamos, usamos e protegemos seus dados pessoais, em conformidade com a <strong>LGPD (Lei 13.709/2018)</strong> e o <strong>GDPR (Regulamento UE 2016/679)</strong>.</p>
<h2>1. Quais dados coletamos</h2>
<ul>
<li><strong>Conta:</strong> e-mail, senha (hash bcrypt), nome opcional.</li>
<li><strong>Dados de bordo:</strong> registros de viagens, manutenções, fotos/áudios/vídeos que VOCÊ adicionar, posições GPS quando você ativa rastreio ou vigia.</li>
<li><strong>Pagamentos:</strong> processados pela Asaas (parceiro PCI-DSS). Não armazenamos número de cartão.</li>
<li><strong>Logs técnicos:</strong> IP, timestamps, ações sensíveis (criar/revogar share, sync de estado) guardados por 90 dias para auditoria de segurança.</li>
</ul>
<h2>2. O que NÃO coletamos</h2>
<ul>
<li> Analytics de comportamento (Google Analytics, Facebook Pixel, etc).</li>
<li> Tracking entre apps/sites.</li>
<li> Anúncios de terceiros.</li>
<li> Compartilhamento com brokers de dados.</li>
</ul>
<h2>3. Para que usamos seus dados</h2>
<ul>
<li>Operar o serviço (sync, vigia de fundeio, alarme remoto).</li>
<li>Processar pagamentos (apenas Asaas).</li>
<li>Enviar e-mails operacionais (recuperação de senha, confirmação de pagamento, alerta de fundeio).</li>
<li>Cumprir obrigações legais (notas fiscais, intimações judiciais quando aplicável).</li>
</ul>
<h2>4. Onde seus dados ficam</h2>
<p>Servidores próprios em data center na Alemanha (Hetzner Online GmbH, certificado ISO 27001), gerenciados pela PontualTech. Backups criptografados.</p>
<h2>5. Permissões do app Android</h2>
<ul>
<li><strong>Localização (incluindo background):</strong> imprescindível pra GPS de viagens, vigia de fundeio com alarme de drift e compartilhamento ao vivo.</li>
<li><strong>Câmera/Microfone/Galeria:</strong> apenas quando você anexar mídia a um registro.</li>
<li><strong>Notificações:</strong> alarme local de fundeio (toca som + vibra mesmo com tela apagada).</li>
<li><strong>Sensores (bússola, barômetro):</strong> usados localmente no celular, não transmitidos.</li>
</ul>
<h2>6. Compartilhamento de posição ao vivo</h2>
<p>Quando você cria um link público de compartilhamento, qualquer pessoa com o link a posição do barco em tempo real. Você controla a duração e pode revogar a qualquer momento. Os links usam tokens randômicos de 96 bits (impossíveis de adivinhar).</p>
<h2>7. Seus direitos (LGPD/GDPR)</h2>
<p>Você pode, a qualquer momento, solicitar:</p>
<ul>
<li>Acesso aos seus dados (exportar tudo via app).</li>
<li>Correção de dados incorretos.</li>
<li>Exclusão da conta e todos os dados (delete em até 30 dias).</li>
<li>Portabilidade (exportar GPX/CSV/JSON).</li>
<li>Revogar consentimento (cancelar assinatura).</li>
</ul>
<p>Solicitações por <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a>.</p>
<h2>8. Retenção de dados</h2>
<ul>
<li>Conta ativa: enquanto você usar.</li>
<li>Conta cancelada: 30 dias (período de arrependimento), depois exclusão permanente.</li>
<li>Logs de auditoria: 90 dias.</li>
<li>Notas fiscais: 5 anos (exigência legal).</li>
</ul>
<h2>9. Cookies</h2>
<p>O app usa apenas <code>localStorage</code> e <code>IndexedDB</code> locais (não são cookies HTTP). Sem cookies de tracking de terceiros.</p>
<h2>10. Crianças</h2>
<p>O Shivao não é destinado a menores de 13 anos. Não coletamos dados de menores intencionalmente.</p>
<h2>11. Mudanças nesta política</h2>
<p>Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual e histórico em <a href="https://shivao.pontualtech.work/politica">shivao.pontualtech.work/politica</a>.</p>
<h2>12. Contato e DPO</h2>
<p><strong>Encarregado de Dados (DPO):</strong> Karlão · <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a></p>
<p><strong>Suporte:</strong> <a href="mailto:contato@pontualtech.com.br">contato@pontualtech.com.br</a></p>
<p><strong>ANPD (autoridade brasileira):</strong> <a href="https://www.gov.br/anpd">gov.br/anpd</a></p>
<hr style="margin:40px 0;border:none;border-top:1px solid #a07832">
<p style="text-align:center"><small>Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}</small></p>
</body></html>`);
});
// PWA manifest (necessário pra "Add to Home Screen" + APK via PWABuilder) // PWA manifest (necessário pra "Add to Home Screen" + APK via PWABuilder)
app.get('/manifest.json', (req, res) => { app.get('/manifest.json', (req, res) => {
res.json({ res.json({
@ -104,16 +472,16 @@ app.get('/api/info', requireAuth, (req, res) => {
}); });
}); });
// --- State sync (whole JSON blob) --- // --- State sync (whole JSON blob, per-user) ---
app.get('/api/data', requireAuth, (req, res) => { app.get('/api/data', requireAuth, (req, res) => {
const s = db.getState(); const s = db.getState(req.user.id);
res.json(s); res.json(s);
}); });
app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => { app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
const { data } = req.body; const { data } = req.body;
const ts = db.setState(data); const ts = db.setState(req.user.id, data);
db.audit('state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip); db.audit(req.user.id, 'state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
res.json({ ok: true, updated_at: ts }); res.json({ ok: true, updated_at: ts });
}); });
@ -144,28 +512,28 @@ app.post('/api/media', requireAuth, upload.single('file'), (req, res) => {
filename: req.file.filename, filename: req.file.filename,
created_at: parseInt(req.body.created_at) || Date.now() created_at: parseInt(req.body.created_at) || Date.now()
}; };
// remove existing if any (overwrite) // remove existing if any (overwrite — escopo do user)
const ex = db.getMedia(id); const ex = db.getMedia(req.user.id, id);
if (ex && ex.filename !== meta.filename) { if (ex && ex.filename !== meta.filename) {
try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {} try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {}
db.deleteMedia(id); db.deleteMedia(req.user.id, id);
} else if (ex) { } else if (ex) {
db.deleteMedia(id); db.deleteMedia(req.user.id, id);
} }
db.insertMedia(meta); db.insertMedia(req.user.id, meta);
db.audit('media_insert', 'media', id, { kind: meta.kind, mime: meta.mime, size: meta.size }, req.ip); db.audit(req.user.id, 'media_insert', 'media', id, { kind: meta.kind, mime: meta.mime, size: meta.size }, req.ip);
res.json({ ok: true, id, url: `/api/media/${id}` }); res.json({ ok: true, id, url: `/api/media/${id}` });
}); });
app.get('/api/media/list', requireAuth, (req, res) => { app.get('/api/media/list', requireAuth, (req, res) => {
res.json(db.listMedia().map(m => ({ res.json(db.listMedia(req.user.id).map(m => ({
id: m.id, parent_id: m.parent_id, parent_type: m.parent_type, id: m.id, parent_id: m.parent_id, parent_type: m.parent_type,
kind: m.kind, mime: m.mime, size: m.size, created_at: m.created_at kind: m.kind, mime: m.mime, size: m.size, created_at: m.created_at
}))); })));
}); });
app.get('/api/media/:id', requireAuth, (req, res) => { app.get('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id); const m = db.getMedia(req.user.id, req.params.id);
if (!m) return res.status(404).json({ error: 'not found' }); if (!m) return res.status(404).json({ error: 'not found' });
const filepath = path.join(mediaDir, m.filename); const filepath = path.join(mediaDir, m.filename);
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'file missing' }); if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'file missing' });
@ -175,11 +543,11 @@ app.get('/api/media/:id', requireAuth, (req, res) => {
}); });
app.delete('/api/media/:id', requireAuth, (req, res) => { app.delete('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id); const m = db.getMedia(req.user.id, req.params.id);
if (!m) return res.status(404).json({ error: 'not found' }); if (!m) return res.status(404).json({ error: 'not found' });
try { fs.unlinkSync(path.join(mediaDir, m.filename)); } catch (e) {} try { fs.unlinkSync(path.join(mediaDir, m.filename)); } catch (e) {}
db.deleteMedia(req.params.id); db.deleteMedia(req.user.id, req.params.id);
db.audit('media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip); db.audit(req.user.id, 'media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip);
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -188,7 +556,7 @@ app.post('/api/anchor/start', requireAuth, (req, res) => {
const { boat_name, anchor_lat, anchor_lng, radius } = req.body; const { boat_name, anchor_lat, anchor_lng, radius } = req.body;
if (typeof anchor_lat !== 'number' || typeof anchor_lng !== 'number') if (typeof anchor_lat !== 'number' || typeof anchor_lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' }); return res.status(400).json({ error: 'lat/lng required' });
db.setAnchor({ db.setAnchor(req.user.id, {
active: true, active: true,
boat_name: boat_name || 'Veleiro', boat_name: boat_name || 'Veleiro',
anchor_lat, anchor_lng, anchor_lat, anchor_lng,
@ -205,13 +573,13 @@ app.post('/api/anchor/start', requireAuth, (req, res) => {
app.post('/api/anchor/heartbeat', requireAuth, (req, res) => { app.post('/api/anchor/heartbeat', requireAuth, (req, res) => {
const { lat, lng, distance } = req.body; const { lat, lng, distance } = req.body;
db.updateHeartbeat(lat, lng, distance || 0); db.updateHeartbeat(req.user.id, lat, lng, distance || 0);
const a = db.getAnchor(); const a = db.getAnchor(req.user.id);
res.json({ ok: true, active: !!a?.active }); res.json({ ok: true, active: !!a?.active });
}); });
app.post('/api/anchor/alarm', requireAuth, async (req, res) => { app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
const a = db.getAnchor(); const a = db.getAnchor(req.user.id);
const payload = { const payload = {
boat: req.body.boat_name || a?.boat_name || 'Veleiro', boat: req.body.boat_name || a?.boat_name || 'Veleiro',
lat: req.body.lat ?? a?.last_lat, lat: req.body.lat ?? a?.last_lat,
@ -221,36 +589,36 @@ app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
reason: req.body.reason || 'drift', reason: req.body.reason || 'drift',
ts: Date.now() ts: Date.now()
}; };
db.setAlarmFired(true); db.setAlarmFired(req.user.id, true);
const result = await dispatchAlarm(payload); const result = await dispatchAlarm(payload);
db.logAlarm('drift', payload, result.sent, result.failed); db.logAlarm(req.user.id, 'drift', payload, result.sent, result.failed);
res.json(result); res.json(result);
}); });
app.post('/api/anchor/stop', requireAuth, (req, res) => { app.post('/api/anchor/stop', requireAuth, (req, res) => {
db.clearAnchor(); db.clearAnchor(req.user.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get('/api/anchor/status', requireAuth, (req, res) => { app.get('/api/anchor/status', requireAuth, (req, res) => {
res.json(db.getAnchor() || { active: 0 }); res.json(db.getAnchor(req.user.id) || { active: 0 });
}); });
// --- Test endpoint --- // --- Test endpoint ---
app.post('/api/test', requireAuth, async (req, res) => { app.post('/api/test', requireAuth, async (req, res) => {
const result = await dispatchTest(); const result = await dispatchTest();
db.logAlarm('test', {}, result.sent, result.failed); db.logAlarm(req.user.id, 'test', {}, result.sent, result.failed);
res.json(result); res.json(result);
}); });
app.get('/api/alarms', requireAuth, (req, res) => { app.get('/api/alarms', requireAuth, (req, res) => {
res.json(db.recentAlarms(50)); res.json(db.recentAlarms(req.user.id, 50));
}); });
// Auditoria (consulta autenticada — quem fez o quê e quando nos endpoints sensíveis) // Auditoria (consulta autenticada — quem fez o quê e quando nos endpoints sensíveis do user)
app.get('/api/audit', requireAuth, (req, res) => { app.get('/api/audit', requireAuth, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500); const limit = Math.min(parseInt(req.query.limit) || 100, 500);
res.json(db.recentAudit(limit)); res.json(db.recentAudit(req.user.id, limit));
}); });
// ==== LIVE SHARE ==== // ==== LIVE SHARE ====
@ -262,8 +630,8 @@ app.post('/api/share/create', requireAuth, (req, res) => {
return res.status(400).json({ error: 'invalid duration' }); return res.status(400).json({ error: 'invalid duration' });
const token = crypto.randomBytes(12).toString('base64url'); const token = crypto.randomBytes(12).toString('base64url');
const expiresAt = Date.now() + durationMinutes * 60 * 1000; const expiresAt = Date.now() + durationMinutes * 60 * 1000;
db.createShare(token, boatName || 'Shivao', expiresAt, zones); db.createShare(req.user.id, token, boatName || 'Shivao', expiresAt, zones);
db.audit('share_create', 'share', token, { boatName: boatName || 'Shivao', expiresAt, zonesCount: Array.isArray(zones) ? zones.length : 0 }, req.ip); db.audit(req.user.id, 'share_create', 'share', token, { boatName: boatName || 'Shivao', expiresAt, zonesCount: Array.isArray(zones) ? zones.length : 0 }, req.ip);
const proto = req.headers['x-forwarded-proto'] || req.protocol; const proto = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.headers['x-forwarded-host'] || req.headers.host; const host = req.headers['x-forwarded-host'] || req.headers.host;
const url = `${proto}://${host}/share/${token}`; const url = `${proto}://${host}/share/${token}`;
@ -271,21 +639,21 @@ app.post('/api/share/create', requireAuth, (req, res) => {
}); });
app.get('/api/share/list', requireAuth, (req, res) => { app.get('/api/share/list', requireAuth, (req, res) => {
res.json(db.listActiveShares().map(s => ({ res.json(db.listActiveShares(req.user.id).map(s => ({
token: s.token, boatName: s.boat_name, expiresAt: s.expires_at, createdAt: s.created_at token: s.token, boatName: s.boat_name, expiresAt: s.expires_at, createdAt: s.created_at
}))); })));
}); });
app.delete('/api/share/:token', requireAuth, (req, res) => { app.delete('/api/share/:token', requireAuth, (req, res) => {
db.revokeShare(req.params.token); db.revokeShare(req.user.id, req.params.token);
db.audit('share_revoke', 'share', req.params.token, {}, req.ip); db.audit(req.user.id, 'share_revoke', 'share', req.params.token, {}, req.ip);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.post('/api/share/:token/zones', requireAuth, validate(updateZonesSchema), (req, res) => { app.post('/api/share/:token/zones', requireAuth, validate(updateZonesSchema), (req, res) => {
const { zones } = req.body; const { zones } = req.body;
db.updateShareZones(req.params.token, zones); db.updateShareZones(req.user.id, req.params.token, zones);
db.audit('share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip); db.audit(req.user.id, 'share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip);
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -293,8 +661,8 @@ app.post('/api/share/position', requireAuth, (req, res) => {
const { lat, lng, speed, boatName } = req.body; const { lat, lng, speed, boatName } = req.body;
if (typeof lat !== 'number' || typeof lng !== 'number') if (typeof lat !== 'number' || typeof lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' }); return res.status(400).json({ error: 'lat/lng required' });
// posta para todos os shares ativos do barco // posta apenas para shares do user logado
const active = db.listActiveShares(); const active = db.listActiveShares(req.user.id);
let posted = 0; let posted = 0;
for (const s of active) { for (const s of active) {
if (boatName && s.boat_name && s.boat_name !== boatName) continue; if (boatName && s.boat_name && s.boat_name !== boatName) continue;
@ -436,18 +804,19 @@ setInterval(() => {
} catch (e) { console.warn(e); } } catch (e) { console.warn(e); }
}, 24 * 3600 * 1000); }, 24 * 3600 * 1000);
// ==== Dead-man switch background check ==== // ==== Dead-man switch background check (multi-user) ====
let lastDeadmanFire = 0; const lastDeadmanFire = new Map(); // user_id -> ts
async function checkDeadman() { async function checkDeadman() {
const a = db.getAnchor();
if (!a || !a.active) return;
const now = Date.now(); const now = Date.now();
const sessions = db.listActiveAnchors();
for (const a of sessions) {
if (!a.active) continue;
const since = now - (a.last_heartbeat || a.started_at); const since = now - (a.last_heartbeat || a.started_at);
if (since < HEARTBEAT_TIMEOUT) return; if (since < HEARTBEAT_TIMEOUT) continue;
// already fired recently? avoid spam const last = lastDeadmanFire.get(a.user_id) || 0;
if (now - lastDeadmanFire < HEARTBEAT_TIMEOUT) return; if (now - last < HEARTBEAT_TIMEOUT) continue;
lastDeadmanFire = now; lastDeadmanFire.set(a.user_id, now);
console.log(`[deadman] No heartbeat in ${Math.round(since/1000)}s — firing remote alarm`); console.log(`[deadman] user=${a.user_id} no heartbeat in ${Math.round(since/1000)}s — firing alarm`);
const payload = { const payload = {
boat: a.boat_name || 'Veleiro', boat: a.boat_name || 'Veleiro',
lat: a.last_lat, lng: a.last_lng, lat: a.last_lat, lng: a.last_lng,
@ -457,8 +826,13 @@ async function checkDeadman() {
ts: now, ts: now,
minutes_lost: Math.round(since / 60000) minutes_lost: Math.round(since / 60000)
}; };
try {
const result = await dispatchAlarm(payload); const result = await dispatchAlarm(payload);
db.logAlarm('heartbeat_lost', payload, result.sent, result.failed); db.logAlarm(a.user_id, 'heartbeat_lost', payload, result.sent, result.failed);
} catch (e) {
console.warn(`[deadman] dispatch failed user=${a.user_id}:`, e.message);
}
}
} }
setInterval(checkDeadman, 30000); // check every 30s setInterval(checkDeadman, 30000); // check every 30s

View file

@ -30,6 +30,25 @@ export const updateZonesSchema = z.object({
zones: z.array(zoneSchema).max(100), zones: z.array(zoneSchema).max(100),
}); });
// ===== Auth (signup + login) =====
export const signupSchema = z.object({
email: z.string().email().max(200),
password: z.string().min(8).max(100),
name: z.string().max(100).optional(),
});
export const loginSchema = z.object({
email: z.string().email().max(200),
password: z.string().min(1).max(100),
});
// ===== Billing (checkout) =====
export const checkoutSchema = z.object({
plan: z.enum(['pro', 'captain']),
cycle: z.enum(['monthly', 'yearly']),
billingType: z.enum(['PIX', 'CREDIT_CARD', 'BOLETO', 'UNDEFINED']).default('PIX'),
});
// ===== Middleware genérico ===== // ===== Middleware genérico =====
// Uso: app.post('/x', requireAuth, validate(mySchema), handler) // Uso: app.post('/x', requireAuth, validate(mySchema), handler)
// Em caso de falha: 400 com até 5 issues do Zod (path + message). // Em caso de falha: 400 com até 5 issues do Zod (path + message).