Files
vyndr/web/src/components/PushPrompt.tsx
T

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