feat: push notifications

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 23:27:02 +01:00
parent ed4062b350
commit 9a61f16959
8 changed files with 683 additions and 9 deletions

View File

@@ -21,6 +21,22 @@
</div>
</div>
<div class="flex items-center space-x-3">
<button
v-if="!pushEnabled"
@click="enablePushNotifications"
class="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
title="Activar notificaciones push"
>
<BellIcon class="w-5 h-5" />
</button>
<button
v-else
@click="disablePushNotifications"
class="p-2 rounded-md text-green-600 dark:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
title="Desactivar notificaciones push"
>
<BellSlashIcon class="w-5 h-5" />
</button>
<button
@click="toggleDarkMode"
class="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
@@ -158,7 +174,10 @@ import {
XMarkIcon,
SunIcon,
MoonIcon,
BellIcon,
BellSlashIcon,
} from '@heroicons/vue/24/outline';
import pushNotificationService from './services/pushNotifications';
const navItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon },
@@ -172,6 +191,7 @@ const wsConnected = ref(false);
const mobileMenuOpen = ref(false);
const darkMode = ref(false);
const toasts = ref([]);
const pushEnabled = ref(false);
let ws = null;
let toastIdCounter = 0;
@@ -219,9 +239,52 @@ function initDarkMode() {
}
}
onMounted(() => {
async function enablePushNotifications() {
try {
const success = await pushNotificationService.init();
if (success) {
pushEnabled.value = true;
// Mostrar notificación simple usando la API de notificaciones del navegador
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Notificaciones activadas', {
body: 'Ahora recibirás notificaciones push cuando haya nuevos artículos',
icon: '/android-chrome-192x192.png',
});
}
} else {
alert('No se pudieron activar las notificaciones push. Verifica que los permisos estén habilitados.');
}
} catch (error) {
console.error('Error activando notificaciones push:', error);
alert('Error activando notificaciones push: ' + error.message);
}
}
async function disablePushNotifications() {
try {
await pushNotificationService.unsubscribe();
pushEnabled.value = false;
// Mostrar notificación simple
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Notificaciones desactivadas', {
body: 'Ya no recibirás notificaciones push',
icon: '/android-chrome-192x192.png',
});
}
} catch (error) {
console.error('Error desactivando notificaciones push:', error);
}
}
async function checkPushStatus() {
const hasSubscription = await pushNotificationService.checkSubscription();
pushEnabled.value = hasSubscription;
}
onMounted(async () => {
initDarkMode();
connectWebSocket();
await checkPushStatus();
});
onUnmounted(() => {

View File

@@ -25,3 +25,17 @@ const app = createApp(App);
app.use(router);
app.mount('#app');
// Registrar Service Worker automáticamente al cargar la app
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registrado:', registration.scope);
} catch (error) {
console.error('Error registrando Service Worker:', error);
}
});
}

View File

@@ -0,0 +1,195 @@
// Servicio para manejar notificaciones push
class PushNotificationService {
constructor() {
this.registration = null;
this.subscription = null;
}
// Registrar Service Worker
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
this.registration = registration;
console.log('Service Worker registrado:', registration.scope);
return registration;
} catch (error) {
console.error('Error registrando Service Worker:', error);
return null;
}
} else {
console.warn('Service Workers no están soportados en este navegador');
return null;
}
}
// Solicitar permisos de notificación
async requestPermission() {
if (!('Notification' in window)) {
console.warn('Este navegador no soporta notificaciones');
return 'denied';
}
if (Notification.permission === 'granted') {
return 'granted';
}
if (Notification.permission === 'denied') {
return 'denied';
}
try {
const permission = await Notification.requestPermission();
return permission;
} catch (error) {
console.error('Error solicitando permiso:', error);
return 'denied';
}
}
// Suscribirse a notificaciones push
async subscribe() {
if (!this.registration) {
await this.registerServiceWorker();
}
if (!this.registration) {
throw new Error('No se pudo registrar el Service Worker');
}
try {
// Verificar si ya existe una suscripción
this.subscription = await this.registration.pushManager.getSubscription();
if (this.subscription) {
console.log('Ya existe una suscripción push');
return this.subscription;
}
// Obtener la clave pública del servidor
const response = await fetch('/api/push/public-key');
const { publicKey } = await response.json();
// Convertir la clave pública a formato ArrayBuffer
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
// Crear nueva suscripción
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('Suscripción push creada:', this.subscription);
// Enviar la suscripción al servidor
await this.sendSubscriptionToServer(this.subscription);
return this.subscription;
} catch (error) {
console.error('Error suscribiéndose a push:', error);
throw error;
}
}
// Enviar suscripción al servidor
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
});
if (!response.ok) {
throw new Error('Error enviando suscripción al servidor');
}
console.log('Suscripción enviada al servidor');
return await response.json();
} catch (error) {
console.error('Error enviando suscripción:', error);
throw error;
}
}
// Cancelar suscripción
async unsubscribe() {
if (this.subscription) {
try {
await this.subscription.unsubscribe();
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.subscription),
});
this.subscription = null;
console.log('Suscripción cancelada');
return true;
} catch (error) {
console.error('Error cancelando suscripción:', error);
return false;
}
}
return false;
}
// Verificar estado de suscripción
async checkSubscription() {
if (!this.registration) {
await this.registerServiceWorker();
}
if (!this.registration) {
return false;
}
try {
this.subscription = await this.registration.pushManager.getSubscription();
return !!this.subscription;
} catch (error) {
console.error('Error verificando suscripción:', error);
return false;
}
}
// Convertir clave pública de base64 URL a Uint8Array
urlBase64ToUint8Array(base64String) {
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;
}
// Inicializar todo el proceso
async init() {
const permission = await this.requestPermission();
if (permission === 'granted') {
await this.registerServiceWorker();
try {
await this.subscribe();
return true;
} catch (error) {
console.error('Error inicializando notificaciones push:', error);
return false;
}
}
return false;
}
}
// Exportar instancia singleton
export default new PushNotificationService();