Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user