feat(c6): notificações e push — Web Push VAPID, badge dinâmico, Share API

FR-6.1/6.2: Sandra recebe push quando pedido entra em pending_approval;
Rafael recebe quando pedido é aprovado ou recusado. Service worker registrado
em background (PWA-ready via public/sw.js).

FR-6.3: Badge na Topbar busca GET /notifications/pending-count (supervisores
veem count de pending_approval; reps veem 0). Intervalo de 30s.

FR-6.4: Botão Compartilhar no OrderDetailPage para pedidos approved/invoiced
(apenas reps). Usa navigator.share() com texto formatado para WhatsApp.

Infra: modelo PushSubscription (Prisma), NotificationsModule (subscribe/
unsubscribe/pending-count + PushService VAPID), VAPID keys em .env,
integração no OrdersService (create → supervisores, approve/reject → repId).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:31:13 +00:00
parent e3587e680a
commit a1a852c44d
22 changed files with 522 additions and 18 deletions

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { apiFetch } from '../api-client';
const VAPID_PUBLIC_KEY = import.meta.env['VITE_VAPID_PUBLIC_KEY'] as string | undefined;
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(b64);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
export function usePushRegistration() {
useEffect(() => {
if (!VAPID_PUBLIC_KEY || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
const register = async () => {
try {
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
const sub =
existing ??
(await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
}));
const json = sub.toJSON();
await apiFetch('/notifications/subscribe', {
method: 'POST',
body: {
endpoint: sub.endpoint,
keys: { p256dh: json.keys?.['p256dh'] ?? '', auth: json.keys?.['auth'] ?? '' },
},
});
} catch {
// Push é opt-in — permissão negada ou SW não disponível é normal
}
};
void register();
}, []);
}