Vitron-Front/app/hooks/usePushNotifications.ts
Arda Samadi fef53fd003 feat(notifications): web push opt-in + unread nav badges
Surfaces three events without a full notification center:
- service worker (public/sw.js) gains push + notificationclick handlers
  (RTL, focuses an existing tab or opens the target URL).
- usePushNotifications hook: permission + VAPID subscribe/unsubscribe
  against the backend push endpoints.
- opt-in UI both ways: a one-time dismissible post-login banner
  (PushPermissionBanner, rendered in MyLayout) and a permanent toggle
  (PushSettingToggle) on buyer profile settings and seller store settings.
- useBadgeCounts polls /api/notif/v1/badges/; small red badges on the
  bottom-nav profile icon (chat + order updates), the profile sidebar
  (پیام‌ها / سفارش‌ها) and the seller dashboard sidebar. Visiting orders
  clears its badge.

Degrades gracefully: if the backend endpoints are absent the counts fall
back to zero and nothing throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:02:56 +03:30

130 lines
4.0 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
/**
* Web Push subscription management.
*
* Flow: ask permission -> fetch VAPID public key -> subscribe via the service
* worker's PushManager -> POST the subscription to the backend. Disabling
* unsubscribes locally and tells the backend to drop the row.
*/
type PermissionState = NotificationPermission | "unsupported";
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
// Back the array with a concrete ArrayBuffer so it satisfies BufferSource.
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length));
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function isPushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
export function usePushNotifications() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const [permission, setPermission] = useState<PermissionState>("default");
const [isSubscribed, setIsSubscribed] = useState(false);
const [busy, setBusy] = useState(false);
// Reflect current permission + subscription state on mount.
useEffect(() => {
if (!isPushSupported()) {
setPermission("unsupported");
return;
}
setPermission(Notification.permission);
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => setIsSubscribed(!!sub))
.catch(() => setIsSubscribed(false));
}, []);
const authHeaders = useCallback(() => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
const subscribe = useCallback(async (): Promise<boolean> => {
if (!isPushSupported() || !token) return false;
setBusy(true);
try {
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== "granted") return false;
// Get the VAPID public key from the backend.
const keyRes = await fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`);
const { public_key: publicKey } = await keyRes.json();
if (!publicKey) return false;
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
const res = await fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
});
const ok = res.ok;
setIsSubscribed(ok);
return ok;
} catch (e) {
console.error("Push subscribe failed:", e);
return false;
} finally {
setBusy(false);
}
}, [apiBase, token, authHeaders]);
const disable = useCallback(async (): Promise<void> => {
if (!isPushSupported()) return;
setBusy(true);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ endpoint: sub.endpoint }),
}).catch(() => {});
await sub.unsubscribe().catch(() => {});
}
setIsSubscribed(false);
} finally {
setBusy(false);
}
}, [apiBase, authHeaders]);
return {
supported: permission !== "unsupported",
permission,
isSubscribed,
busy,
subscribe,
disable,
};
}