push notification implemented
need testing
This commit is contained in:
1 parent
26a17b5a4c
commit
0bbf2ab56f
19 files changed
+907
-84
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
import { Bell, X } from 'lucide-react';
|
||||
|
||||
const DISMISSED_KEY = 'push-banner-dismissed';
|
||||
const SHOW_DELAY = 3000;
|
||||
|
||||
function getInitiallyDismissed(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem(DISMISSED_KEY);
|
||||
}
|
||||
|
||||
export function NotificationBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(getInitiallyDismissed);
|
||||
const { isSupported, isSubscribed, isLoading, subscribe } =
|
||||
usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupported && !isSubscribed && !isDismissed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, SHOW_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isSupported, isSubscribed, isDismissed]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
localStorage.setItem(DISMISSED_KEY, 'true');
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
const success = await subscribe();
|
||||
if (success) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible || isSubscribed || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm animate-slide-in-right">
|
||||
<div className="flex items-start gap-3 rounded-lg border-2 border-foreground bg-card p-4 shadow-brutal">
|
||||
<div className="flex-shrink-0">
|
||||
<Bell className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-body text-sm font-medium uppercase tracking-wider">
|
||||
Останете информирани
|
||||
</p>
|
||||
<p className="mt-1 font-body text-xs text-muted-foreground">
|
||||
Добијте известувања за нови статии и live ажурирања
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={isLoading}
|
||||
className="border-2 border-foreground bg-foreground px-3 py-1.5 font-body text-xs font-medium uppercase tracking-wider text-background transition-all hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Се вклучува...' : 'Вклучи'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="border-2 border-foreground px-3 py-1.5 font-body text-xs font-medium uppercase tracking-wider transition-all hover:bg-muted"
|
||||
>
|
||||
Не сега
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Затвори"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
getVapidPublicKey,
|
||||
subscribeToPush,
|
||||
unsubscribeFromPush,
|
||||
urlBase64ToUint8Array,
|
||||
} from '@/lib/push-api';
|
||||
import type { PushSubscriptionData } from '@/lib/push-api';
|
||||
|
||||
const STORAGE_KEY = 'push-notification-preference';
|
||||
|
||||
function checkPushSupport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialPermission(): NotificationPermission | 'not-supported' {
|
||||
if (!checkPushSupport()) return 'not-supported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
export interface UsePushNotificationsReturn {
|
||||
isSupported: boolean;
|
||||
isSubscribed: boolean;
|
||||
isLoading: boolean;
|
||||
permissionState: NotificationPermission | 'not-supported';
|
||||
subscribe: () => Promise<boolean>;
|
||||
unsubscribe: () => Promise<boolean>;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
}
|
||||
|
||||
export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
const [isSupported] = useState(checkPushSupport);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [permissionState, setPermissionState] = useState(getInitialPermission);
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupported || hasCheckedRef.current) return;
|
||||
hasCheckedRef.current = true;
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => registration.pushManager.getSubscription())
|
||||
.then((subscription) => {
|
||||
setIsSubscribed(!!subscription);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error checking subscription:', error);
|
||||
});
|
||||
}, [isSupported]);
|
||||
|
||||
const requestPermission =
|
||||
useCallback(async (): Promise<NotificationPermission> => {
|
||||
if (!isSupported) return 'not-supported';
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
setPermissionState(permission);
|
||||
return permission;
|
||||
}, [isSupported]);
|
||||
|
||||
const subscribe = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported) return false;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const permission = await requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const publicKey = await getVapidPublicKey();
|
||||
if (!publicKey) {
|
||||
console.error('VAPID public key not available');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
const subJson = subscription.toJSON() as PushSubscriptionData;
|
||||
const success = await subscribeToPush(subJson);
|
||||
|
||||
if (success) {
|
||||
setIsSubscribed(true);
|
||||
localStorage.setItem(STORAGE_KEY, 'subscribed');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push notifications:', error);
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, [isSupported, requestPermission]);
|
||||
|
||||
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported) return false;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await unsubscribeFromPush(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
setIsSubscribed(false);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setIsLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing from push notifications:', error);
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isSubscribed,
|
||||
isLoading,
|
||||
permissionState,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VapidPublicKeyResponse {
|
||||
publicKey: string | null;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
export async function getVapidPublicKey(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/vapid-public-key`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VAPID public key');
|
||||
}
|
||||
const data: VapidPublicKeyResponse = await response.json();
|
||||
return data.publicKey;
|
||||
} catch (error) {
|
||||
console.error('Error fetching VAPID public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToPush(
|
||||
subscription: PushSubscriptionData,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(
|
||||
endpoint: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing from push:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Header } from './components/layout/Header'
|
||||
import { HeroArticle } from './components/home/HeroArticle'
|
||||
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
|
||||
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
|
||||
import { NotificationBanner } from './components/ui/notification-banner'
|
||||
import { Button } from './components/ui/button'
|
||||
import { Zap, Search, Users } from 'lucide-react'
|
||||
import './styles.css'
|
||||
@@ -38,6 +39,8 @@ const rootRoute = createRootRoute({
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<NotificationBanner />
|
||||
|
||||
<footer className="border-t-4 border-foreground bg-foreground text-background">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
|
||||
+3
-2
@@ -32,9 +32,10 @@ self.addEventListener('push', (event: PushEvent) => {
|
||||
const title = data.title ?? 'Placebo.mk'
|
||||
const options = {
|
||||
body: data.body ?? 'New update available',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
icon: data.icon ?? '/icons/icon-192.svg',
|
||||
badge: data.badge ?? '/icons/badge-72.svg',
|
||||
data: data.url ?? '/',
|
||||
tag: data.tag,
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options))
|
||||
|
||||
Reference in new issue
Block a user