All files / src/components/pwa PushPermissionBanner.tsx

98.57% Statements 69/70
97.36% Branches 37/38
91.66% Functions 11/12
100% Lines 59/59

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193            3x 3x 3x 3x     32x       10x         26x     15x     14x 26x     13x 13x 1x 1x     12x       5x 5x 5x       4x     4x       3x 3x 4x   2x         2x 2x 2x 1x     1x               2x 2x 2x 2x 2x 20x   2x       65x 65x   65x 26x     65x   21x 6x 6x 6x 6x 4x 1x     1x   2x 2x     3x   3x         3x   6x       21x 3x 3x     21x                                                                                                                            
import { useState, useEffect } from "react";
import { Bell, X } from "lucide-react";
import { Button } from "../ui/button";
import { notificationsApi } from "../../lib/api";
import { notify } from "../../lib/notify";
 
const DENIAL_COUNT_KEY = "push_denial_count";
const DENIED_AT_KEY = "push_denied_at";
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const MAX_DENIALS = 5;
 
function safeGetItem(key: string): string | null {
	try { return localStorage.getItem(key); } catch { return null; }
}
 
function safeSetItem(key: string, value: string): void {
	try { localStorage.setItem(key, value); } catch { /* storage unavailable */ }
}
 
function shouldShowBanner(): boolean {
	// Don't show if push not supported
	if (!("PushManager" in window)) return false;
 
	// Don't show if already granted
	if (typeof Notification !== "undefined" && Notification.permission === "granted") return false;
 
	// Check denial count
	const denialCount = Number(safeGetItem(DENIAL_COUNT_KEY) ?? 0);
	if (denialCount >= MAX_DENIALS) return false;
 
	// Check cooldown after last denial
	const deniedAt = safeGetItem(DENIED_AT_KEY);
	if (deniedAt) {
		const elapsed = Date.now() - Number(deniedAt);
		Eif (elapsed < THREE_DAYS_MS) return false;
	}
 
	return true;
}
 
function incrementDenial() {
	const count = Number(safeGetItem(DENIAL_COUNT_KEY) ?? 0);
	safeSetItem(DENIAL_COUNT_KEY, String(count + 1));
	safeSetItem(DENIED_AT_KEY, String(Date.now()));
}
 
async function subscribeToPush() {
	const reg = await Promise.race([
		navigator.serviceWorker.ready,
		new Promise<never>((_, reject) =>
			setTimeout(() => reject(new Error("Service worker not available")), 10_000),
		),
	]);
 
	const vapidRes = await notificationsApi.getVapidPublicKey();
	const publicKey = vapidRes.data?.publicKey;
	if (!publicKey) throw new Error("No VAPID key");
 
	const sub = await reg.pushManager.subscribe({
		userVisibleOnly: true,
		applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
	});
 
	const json = sub.toJSON();
	const keys = json.keys as { p256dh: string; auth: string } | undefined;
	if (!keys?.p256dh || !keys?.auth) {
		throw new Error("Push subscription has invalid keys");
	}
 
	await notificationsApi.subscribe({
		endpoint: sub.endpoint,
		p256dh: keys.p256dh,
		auth: keys.auth,
	});
}
 
function urlBase64ToUint8Array(base64String: string): Uint8Array {
	const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
	const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
	const rawData = atob(base64);
	const outputArray = new Uint8Array(rawData.length);
	for (let i = 0; i < rawData.length; i++) {
		outputArray[i] = rawData.charCodeAt(i);
	}
	return outputArray;
}
 
export function PushPermissionBanner() {
	const [visible, setVisible] = useState(false);
	const [isLoading, setIsLoading] = useState(false);
 
	useEffect(() => {
		setVisible(shouldShowBanner());
	}, []);
 
	if (!visible) return null;
 
	const handleEnable = async () => {
		setIsLoading(true);
		try {
			const permission = await Notification.requestPermission();
			if (permission === "granted") {
				await subscribeToPush();
				notify.success(
					"Notifications enabled! Make sure notifications are also turned on for your browser in your device settings.",
				);
				setVisible(false);
			} else {
				incrementDenial();
				setVisible(false);
			}
		} catch (err) {
			console.error("[Push] Subscription failed:", err);
			const message =
				err instanceof Error && err.message === "Service worker not available"
					? "Push notifications require the app to be fully loaded. Please reload and try again."
					: err instanceof Error && err.message === "No VAPID key"
						? "Push notifications are not configured. Please contact support."
						: "Failed to enable push notifications. Please try again.";
			notify.error(message);
		} finally {
			setIsLoading(false);
		}
	};
 
	const handleDismiss = () => {
		incrementDenial();
		setVisible(false);
	};
 
	return (
		<div className="mb-4 rounded-lg border border-primary-200 bg-primary-50 p-2 sm:p-4 dark:border-primary-800 dark:bg-primary-950">
			{/* Mobile: slim single-line bar */}
			<div className="sm:hidden flex items-center gap-2">
				<Bell className="h-4 w-4 flex-shrink-0 text-primary-600" />
				<p className="text-sm font-medium text-primary-800 dark:text-primary-200 flex-1 truncate">
					Get notified of new leads
				</p>
				<button
					type="button"
					onClick={handleEnable}
					disabled={isLoading}
					className="text-xs font-semibold text-primary-700 bg-primary-200 px-2 py-1 rounded-md flex-shrink-0 disabled:opacity-50"
				>
					{isLoading ? "…" : "Enable"}
				</button>
				<button
					type="button"
					onClick={handleDismiss}
					disabled={isLoading}
					className="text-xs text-foreground-muted flex-shrink-0 disabled:opacity-50"
				>
					Later
				</button>
			</div>
 
			{/* Desktop: full card */}
			<div className="hidden sm:flex items-start gap-3">
				<Bell className="mt-0.5 h-5 w-5 flex-shrink-0 text-primary-600" />
				<div className="flex-1 min-w-0">
					<p className="text-sm font-medium text-foreground-default">
						Stay updated with instant notifications
					</p>
					<p className="mt-0.5 text-xs text-foreground-muted">
						Get notified about new enquiries, lead reminders, project milestones, and important updates — even when you're not on the portal.
					</p>
					<div className="mt-3 flex items-center gap-2">
						<Button size="sm" onClick={handleEnable} disabled={isLoading}>
							{isLoading ? "Enabling…" : "Enable Notifications"}
						</Button>
						<Button
							size="sm"
							variant="ghost"
							onClick={handleDismiss}
							disabled={isLoading}
						>
							Maybe Later
						</Button>
					</div>
				</div>
				<button
					type="button"
					aria-label="Dismiss notification banner"
					onClick={handleDismiss}
					className="flex-shrink-0 p-1 text-foreground-muted hover:text-foreground-default rounded"
				>
					<X className="h-4 w-4" />
				</button>
			</div>
		</div>
	);
}