Skip to content

Commit e065bab

Browse files
Thales-Chagasclaude
andcommitted
Notificacoes push: contas a vencer (diario) e lembrete de anotar gastos (5 em 5 dias)
Web Push completo: service worker proprio (injectManifest) com handler de push, botao Ativar/Desativar na aba Conta, tabela push_subscriptions (RLS) e Edge Function notificar (VAPID, agendada via pg_cron, valida X-Notify-Secret). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent df87ac3 commit e065bab

10 files changed

Lines changed: 487 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@ PASSO-A-PASSO-TELEGRAM.md
1919
# Guia local do backup automático — contém segredo (BACKUP_SECRET). Nunca publicar.
2020
PASSO-A-PASSO-BACKUP.md
2121

22+
# Guia local das notificações push — contém segredo (NOTIFY_SECRET). Nunca publicar.
23+
PASSO-A-PASSO-NOTIFICACOES.md
24+
2225
# Configura��o local do Claude Code
2326
.claude/

package-lock.json

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
"@vitejs/plugin-react": "^4.3.4",
2121
"tailwindcss": "^4.0.0",
2222
"vite": "^6.0.0",
23-
"vite-plugin-pwa": "^1.3.0"
23+
"vite-plugin-pwa": "^1.3.0",
24+
"workbox-core": "^7.4.1",
25+
"workbox-precaching": "^7.4.1",
26+
"workbox-routing": "^7.4.1"
2427
}
2528
}

src/App.jsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {
5454
EllipsisVertical,
5555
Lightbulb,
5656
CircleCheck,
57+
Bell,
58+
BellOff,
5759
} from "lucide-react";
5860
import {
5961
ResponsiveContainer,
@@ -87,6 +89,7 @@ import {
8789
} from "./cloudAuth";
8890
import { carregarTudo, sincronizar, migrarLocalParaNuvem } from "./cloudData";
8991
import { gerarCodigoTelegram, statusTelegram, desconectarTelegram, BOT_URL, BOT_USERNAME } from "./telegramLink";
92+
import { suportePush, assinaturaAtual, ativarPush, desativarPush } from "./push";
9093
import {
9194
temAutenticadorPlataforma,
9295
registrarBiometria,
@@ -3086,6 +3089,90 @@ const NAV_EMPRESA = [
30863089
// Aba "Minha Conta": mostra quem está logado, ONDE os dados ficam (nuvem x
30873090
// aparelho) e o status do Telegram. Ajuda a entender por que o saldo pode
30883091
// parecer diferente entre o computador e o celular.
3092+
// Cartão de notificações push da aba Conta: liga/desliga neste aparelho.
3093+
function CartaoNotificacoes({ userId, showToast }) {
3094+
const suporte = suportePush();
3095+
const [ativa, setAtiva] = useState(undefined); // undefined=carregando
3096+
const [ocupado, setOcupado] = useState(false);
3097+
3098+
useEffect(() => {
3099+
let vivo = true;
3100+
assinaturaAtual()
3101+
.then((s) => vivo && setAtiva(!!s))
3102+
.catch(() => vivo && setAtiva(false));
3103+
return () => {
3104+
vivo = false;
3105+
};
3106+
}, []);
3107+
3108+
async function alternar() {
3109+
if (ocupado) return;
3110+
setOcupado(true);
3111+
try {
3112+
if (ativa) {
3113+
await desativarPush();
3114+
setAtiva(false);
3115+
showToast?.("Notificações desligadas neste aparelho.");
3116+
} else {
3117+
await ativarPush(userId);
3118+
setAtiva(true);
3119+
showToast?.("Notificações ativadas! 🔔");
3120+
}
3121+
} catch (e) {
3122+
showToast?.(e.message || "Não deu certo. Tente de novo.", true, 6000);
3123+
} finally {
3124+
setOcupado(false);
3125+
}
3126+
}
3127+
3128+
const descricao =
3129+
suporte === "ios-precisa-instalar"
3130+
? "No iPhone, primeiro instale o app na tela de início (veja em Dicas do app)."
3131+
: suporte === "bloqueado"
3132+
? "As notificações estão bloqueadas nas configurações do navegador."
3133+
: suporte === "sem-suporte"
3134+
? "Este navegador não aceita notificações."
3135+
: ativa === undefined
3136+
? "Verificando..."
3137+
: ativa
3138+
? "Ativas neste aparelho ✅ — contas a vencer e lembrete de anotar os gastos."
3139+
: "Avisa das contas a vencer e, de tempos em tempos, lembra de anotar os gastos.";
3140+
3141+
return (
3142+
<div className="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
3143+
<div className="flex items-center justify-between gap-3">
3144+
<div>
3145+
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-200">
3146+
Notificações no celular
3147+
</h3>
3148+
<p className="mt-0.5 text-sm text-slate-400">{descricao}</p>
3149+
</div>
3150+
{suporte === "ok" && ativa !== undefined && (
3151+
<button
3152+
onClick={alternar}
3153+
disabled={ocupado}
3154+
className={
3155+
"flex shrink-0 items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 " +
3156+
(ativa
3157+
? "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
3158+
: "bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950/50 dark:text-emerald-300 dark:hover:bg-emerald-950")
3159+
}
3160+
>
3161+
{ocupado ? (
3162+
<Loader2 size={15} className="animate-spin" />
3163+
) : ativa ? (
3164+
<BellOff size={15} />
3165+
) : (
3166+
<Bell size={15} />
3167+
)}
3168+
{ativa ? "Desativar" : "Ativar"}
3169+
</button>
3170+
)}
3171+
</div>
3172+
</div>
3173+
);
3174+
}
3175+
30893176
function PaginaConta({ login, sessao, userId, onConectarTelegram, onLimparDados, onSair, showToast, onVerTour }) {
30903177
const email = sessao?.user?.email || null;
30913178
const naNuvem = !!sessao;
@@ -3193,6 +3280,9 @@ function PaginaConta({ login, sessao, userId, onConectarTelegram, onLimparDados,
31933280
</div>
31943281
</div>
31953282

3283+
{/* Notificações push — só faz sentido logado na nuvem */}
3284+
{userId && <CartaoNotificacoes userId={userId} showToast={showToast} />}
3285+
31963286
{/* Explicação do saldo diferente entre aparelhos */}
31973287
<div className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-200">
31983288
<AlertTriangle size={18} className="mt-0.5 shrink-0" />

src/push.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// ============================================================
2+
// Notificações push no celular (Web Push)
3+
// O navegador cria uma "assinatura" única deste aparelho, que fica
4+
// guardada em push_subscriptions (RLS: cada um só vê a própria).
5+
// A Edge Function `notificar` usa essas assinaturas pra enviar:
6+
// contas a vencer (diário) e lembrete de anotar gastos (a cada 5 dias).
7+
// ============================================================
8+
import { supabase } from "./supabaseClient";
9+
10+
// Chave PÚBLICA do servidor de push (VAPID) — não é segredo.
11+
// A privada correspondente vive só nos secrets do Supabase.
12+
const VAPID_PUBLIC_KEY =
13+
"BDrr3qE6H5iFXENcCtXFpT4f1WwAMCKiIzn4W4zBm1mHkBcKJ5FbsHYWx6H2jR4HTYtVjHYi116bCz77sHwNxCo";
14+
15+
const b64UrlParaBytes = (s) => {
16+
const pad = "=".repeat((4 - (s.length % 4)) % 4);
17+
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
18+
const bin = atob(b64);
19+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
20+
};
21+
22+
const ehIos = () => /iphone|ipad|ipod/i.test(navigator.userAgent || "");
23+
const rodandoInstalado = () =>
24+
window.matchMedia?.("(display-mode: standalone)")?.matches ||
25+
window.navigator.standalone === true;
26+
27+
// O que este aparelho consegue fazer:
28+
// "ok" | "ios-precisa-instalar" | "bloqueado" | "sem-suporte"
29+
export function suportePush() {
30+
if (
31+
!("serviceWorker" in navigator) ||
32+
!("Notification" in window) ||
33+
!("PushManager" in window)
34+
) {
35+
// iPhone sem o app instalado nem expõe PushManager — orienta a instalar
36+
if (ehIos() && !rodandoInstalado()) return "ios-precisa-instalar";
37+
return "sem-suporte";
38+
}
39+
if (ehIos() && !rodandoInstalado()) return "ios-precisa-instalar";
40+
if (Notification.permission === "denied") return "bloqueado";
41+
return "ok";
42+
}
43+
44+
// Assinatura deste aparelho, se já existir (null se não)
45+
export async function assinaturaAtual() {
46+
if (!("serviceWorker" in navigator)) return null;
47+
const reg = await navigator.serviceWorker.getRegistration();
48+
if (!reg?.pushManager) return null;
49+
return await reg.pushManager.getSubscription();
50+
}
51+
52+
// Liga as notificações neste aparelho (pede permissão) e guarda na nuvem
53+
export async function ativarPush(userId) {
54+
const permissao = await Notification.requestPermission();
55+
if (permissao !== "granted") {
56+
throw new Error("Permissão negada. Libere as notificações nas configurações do navegador.");
57+
}
58+
const reg = await navigator.serviceWorker.getRegistration();
59+
if (!reg) {
60+
throw new Error("O app ainda está terminando de carregar — tente de novo em instantes.");
61+
}
62+
const sub = await reg.pushManager.subscribe({
63+
userVisibleOnly: true,
64+
applicationServerKey: b64UrlParaBytes(VAPID_PUBLIC_KEY),
65+
});
66+
const json = sub.toJSON();
67+
const { error } = await supabase.from("push_subscriptions").upsert(
68+
{
69+
endpoint: sub.endpoint,
70+
user_id: userId,
71+
p256dh: json.keys.p256dh,
72+
auth: json.keys.auth,
73+
aparelho: (navigator.userAgent || "").slice(0, 200),
74+
},
75+
{ onConflict: "endpoint" }
76+
);
77+
if (error) {
78+
await sub.unsubscribe().catch(() => {});
79+
throw new Error("Não consegui guardar a assinatura: " + error.message);
80+
}
81+
return sub;
82+
}
83+
84+
// Desliga neste aparelho: apaga da nuvem e cancela no navegador
85+
export async function desativarPush() {
86+
const sub = await assinaturaAtual();
87+
if (!sub) return;
88+
await supabase.from("push_subscriptions").delete().eq("endpoint", sub.endpoint);
89+
await sub.unsubscribe().catch(() => {});
90+
}

src/sw.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// ============================================================
2+
// Service worker do Thayfinance (PWA)
3+
// - Precache dos arquivos do app (mesmo comportamento de antes,
4+
// que o vite-plugin-pwa gerava sozinho)
5+
// - Recebe notificações push (contas a vencer + lembrete de anotar)
6+
// ============================================================
7+
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from "workbox-precaching";
8+
import { NavigationRoute, registerRoute } from "workbox-routing";
9+
import { clientsClaim } from "workbox-core";
10+
11+
self.skipWaiting();
12+
clientsClaim();
13+
cleanupOutdatedCaches();
14+
precacheAndRoute(self.__WB_MANIFEST);
15+
16+
// navegação (URLs do app) cai no index.html precacheado — app abre offline
17+
registerRoute(new NavigationRoute(createHandlerBoundToURL("index.html")));
18+
19+
// chegou um push do servidor → mostra a notificação
20+
self.addEventListener("push", (event) => {
21+
let dados = {};
22+
try {
23+
dados = event.data ? event.data.json() : {};
24+
} catch {
25+
dados = { corpo: event.data ? event.data.text() : "" };
26+
}
27+
const titulo = dados.titulo || "Thayfinance";
28+
event.waitUntil(
29+
self.registration.showNotification(titulo, {
30+
body: dados.corpo || "",
31+
icon: "/pwa-192.png",
32+
badge: "/pwa-192.png",
33+
tag: dados.tag || undefined, // mesma tag substitui a anterior (não acumula)
34+
data: { url: dados.url || "/" },
35+
})
36+
);
37+
});
38+
39+
// tocou na notificação → foca o app aberto ou abre uma janela nova
40+
self.addEventListener("notificationclick", (event) => {
41+
event.notification.close();
42+
const url = event.notification.data?.url || "/";
43+
event.waitUntil(
44+
self.clients
45+
.matchAll({ type: "window", includeUncontrolled: true })
46+
.then((janelas) => {
47+
for (const j of janelas) {
48+
if ("focus" in j) return j.focus();
49+
}
50+
return self.clients.openWindow(url);
51+
})
52+
);
53+
});

supabase/config.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ verify_jwt = false
1414
# A própria função valida o header X-Backup-Secret.
1515
[functions.backup]
1616
verify_jwt = false
17+
18+
# Notificações push (chamada pelo agendador pg_cron, sem login Supabase).
19+
# A própria função valida o header X-Notify-Secret.
20+
[functions.notificar]
21+
verify_jwt = false

0 commit comments

Comments
 (0)