feat: push notifications
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
"theme_color": "#0284c7",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"gcm_sender_id": "103953800507"
|
||||
}
|
||||
|
||||
|
||||
100
web/frontend/public/sw.js
Normal file
100
web/frontend/public/sw.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// Service Worker para notificaciones push
|
||||
const CACHE_NAME = 'wallabicher-v1';
|
||||
|
||||
// Instalar Service Worker
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker instalado');
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activar Service Worker
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker activado');
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Manejar mensajes push
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Push recibido:', event);
|
||||
|
||||
let notificationData = {
|
||||
title: 'Wallabicher',
|
||||
body: 'Tienes nuevas notificaciones',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
badge: '/android-chrome-192x192.png',
|
||||
tag: 'wallabicher-notification',
|
||||
requireInteraction: false,
|
||||
data: {}
|
||||
};
|
||||
|
||||
// Si hay datos en el push, usarlos
|
||||
if (event.data) {
|
||||
try {
|
||||
const data = event.data.json();
|
||||
if (data.title) notificationData.title = data.title;
|
||||
if (data.body) notificationData.body = data.body;
|
||||
if (data.icon) notificationData.icon = data.icon;
|
||||
if (data.image) notificationData.image = data.image;
|
||||
if (data.url) notificationData.data.url = data.url;
|
||||
if (data.platform) notificationData.data.platform = data.platform;
|
||||
if (data.price) notificationData.data.price = data.price;
|
||||
if (data.currency) notificationData.data.currency = data.currency;
|
||||
|
||||
// Usar tag único para agrupar notificaciones por artículo
|
||||
if (data.id) notificationData.tag = `article-${data.id}`;
|
||||
} catch (e) {
|
||||
// Si no es JSON, usar como texto
|
||||
notificationData.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(notificationData.title, {
|
||||
body: notificationData.body,
|
||||
icon: notificationData.icon,
|
||||
badge: notificationData.badge,
|
||||
image: notificationData.image,
|
||||
tag: notificationData.tag,
|
||||
requireInteraction: notificationData.requireInteraction,
|
||||
data: notificationData.data,
|
||||
actions: notificationData.data.url ? [
|
||||
{
|
||||
action: 'open',
|
||||
title: 'Ver artículo'
|
||||
},
|
||||
{
|
||||
action: 'close',
|
||||
title: 'Cerrar'
|
||||
}
|
||||
] : []
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Manejar clics en notificaciones
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('Notificación clickeada:', event);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si hay una URL, abrirla
|
||||
if (event.notification.data.url) {
|
||||
event.waitUntil(
|
||||
clients.openWindow(event.notification.data.url)
|
||||
);
|
||||
} else {
|
||||
// Si no hay URL, abrir la app
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar notificaciones cerradas
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('Notificación cerrada:', event);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
195
web/frontend/src/services/pushNotifications.js
Normal file
195
web/frontend/src/services/pushNotifications.js
Normal 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();
|
||||
Reference in New Issue
Block a user