135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
|
|
// Push opt-in banner. Same gating as InstallPrompt: only after 2 Reads.
|
|
// We treat the result tri-state:
|
|
// granted → POST the PushSubscription to /api/push/subscribe
|
|
// denied → remember it; never ask again
|
|
// default → user dismissed; ask again next session
|
|
|
|
const READS_KEY = 'vyndr_reads_completed';
|
|
const ASKED_KEY = 'vyndr_push_asked';
|
|
const DENIED_KEY = 'vyndr_push_denied';
|
|
const REQUIRED_READS = 2;
|
|
|
|
function readsCompleted(): number {
|
|
if (typeof window === 'undefined') return 0;
|
|
const raw = window.localStorage.getItem(READS_KEY);
|
|
return raw ? parseInt(raw, 10) || 0 : 0;
|
|
}
|
|
|
|
function base64UrlToArrayBuffer(base64Url: string): ArrayBuffer {
|
|
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
|
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = window.atob(base64);
|
|
const buffer = new ArrayBuffer(raw.length);
|
|
const view = new Uint8Array(buffer);
|
|
for (let i = 0; i < raw.length; i += 1) view[i] = raw.charCodeAt(i);
|
|
return buffer;
|
|
}
|
|
|
|
export default function PushPrompt() {
|
|
const { user } = useAuth();
|
|
const [visible, setVisible] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
if (typeof window === 'undefined') return;
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
|
if (Notification.permission === 'granted' || Notification.permission === 'denied') return;
|
|
if (window.localStorage.getItem(DENIED_KEY)) return;
|
|
if (window.sessionStorage.getItem(ASKED_KEY)) return;
|
|
if (readsCompleted() < REQUIRED_READS) return;
|
|
setVisible(true);
|
|
}, [user]);
|
|
|
|
const handleEnable = async () => {
|
|
setBusy(true);
|
|
window.sessionStorage.setItem(ASKED_KEY, '1');
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission === 'denied') {
|
|
window.localStorage.setItem(DENIED_KEY, '1');
|
|
setVisible(false);
|
|
return;
|
|
}
|
|
if (permission !== 'granted') {
|
|
setVisible(false);
|
|
return;
|
|
}
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
|
if (!vapidKey) {
|
|
console.warn('[push] NEXT_PUBLIC_VAPID_PUBLIC_KEY not set');
|
|
setVisible(false);
|
|
return;
|
|
}
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: base64UrlToArrayBuffer(vapidKey),
|
|
});
|
|
await fetch('/api/push/subscribe', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ subscription }),
|
|
});
|
|
setVisible(false);
|
|
} catch (err) {
|
|
console.warn('[push] subscribe failed:', err);
|
|
setVisible(false);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleDismiss = () => {
|
|
window.sessionStorage.setItem(ASKED_KEY, '1');
|
|
setVisible(false);
|
|
};
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-label="Enable notifications"
|
|
className="fixed bottom-24 left-4 right-4 z-50 mx-auto max-w-md rounded-lg border p-4 shadow-lg"
|
|
style={{
|
|
background: 'var(--bg-surface)',
|
|
borderColor: 'var(--border-light)',
|
|
color: 'var(--text-primary)',
|
|
}}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1">
|
|
<div className="text-sm font-semibold">Get notified on cascades + A+ alerts</div>
|
|
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
We'll ping you when a prop drops to A+, a cascade triggers, or your reads resolve.
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismiss}
|
|
aria-label="Dismiss notification prompt"
|
|
className="rounded p-1 text-xs"
|
|
style={{ color: 'var(--text-tertiary)' }}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleEnable}
|
|
disabled={busy}
|
|
className="mt-3 w-full rounded px-3 py-2 text-sm font-semibold disabled:opacity-50"
|
|
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
|
>
|
|
{busy ? 'Enabling…' : 'Enable notifications'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|