push notification implemented

need testing
This commit is contained in:
dimitar committed 2026-02-22 02:23:41 +01:00
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>
);
}
+147
View File
@@ -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,
};
}
+82
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -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))