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>
);
}
|