✨ feat(sw.js/app.js): Gère les notifications push et l'abonnement
Ajoute la gestion des notifications push avec abonnement via le
service worker et enregistre l'abonnement sur le serveur. Gère
l'affichage d'une bannière pour demander la permission.
```
419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
import './app.scss'
|
|
import * as Turbo from "@hotwired/turbo"
|
|
|
|
import {PaymentForm} from './PaymentForm'
|
|
|
|
// --- CLÉ VAPID PUBLIQUE DU SERVEUR ---
|
|
// Cette clé est nécessaire pour identifier notre application auprès du service push.
|
|
const VAPID_PUBLIC_KEY = "BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo";
|
|
|
|
|
|
/**
|
|
* Convertit une chaîne Base64 URL Safe en Uint8Array.
|
|
* Nécessaire pour passer la clé VAPID publique à pushManager.subscribe().
|
|
* @param {string} base64String
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function 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;
|
|
}
|
|
|
|
|
|
/**
|
|
* Fonction générique pour basculer la visibilité d'un menu déroulant.
|
|
* @param {HTMLElement} button - Le bouton qui déclenche l'action.
|
|
* @param {HTMLElement} menu - Le menu à afficher/masquer.
|
|
*/
|
|
function toggleMenu(button, menu) {
|
|
if (!button || !menu) return;
|
|
const isExpanded = button.getAttribute('aria-expanded') === 'true' || false;
|
|
button.setAttribute('aria-expanded', !isExpanded);
|
|
menu.classList.toggle('hidden');
|
|
}
|
|
|
|
/**
|
|
* Fonction d'initialisation pour les composants qui DOIVENT être réinitialisés
|
|
* après un chargement Turbo (comme les compteurs d'articles, les états initiaux).
|
|
* Le menu mobile et le panier sont gérés par délégation d'événements.
|
|
*/
|
|
function initializeUI() {
|
|
// Réinitialisation des états des menus cachés après un chargement Turbo,
|
|
// au cas où ils étaient ouverts lors de la navigation précédente.
|
|
document.querySelectorAll('#mobile-menu, #userMenuDesktop, #userMenuMobile').forEach(menu => {
|
|
if (!menu.classList.contains('hidden')) {
|
|
menu.classList.add('hidden');
|
|
}
|
|
});
|
|
document.querySelectorAll('#mobileMenuButton, #userMenuButtonDesktop, #userMenuButtonMobile').forEach(button => {
|
|
button.setAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
|
|
// --- 2. Gestion du Panier Latéral (Off-Canvas) ---
|
|
const cartSidebar = document.getElementById('cartSidebar');
|
|
const cartBackdrop = document.getElementById('cartBackdrop');
|
|
const closeCartButton = document.getElementById('closeCartButton');
|
|
|
|
// Mettez les fonctions ici pour qu'elles soient toujours définies si les éléments existent
|
|
if (cartSidebar && cartBackdrop && closeCartButton) {
|
|
function openCart() {
|
|
document.body.style.overflow = 'hidden';
|
|
cartBackdrop.classList.remove('hidden');
|
|
cartSidebar.classList.remove('translate-x-full');
|
|
cartSidebar.classList.add('translate-x-0');
|
|
}
|
|
|
|
function closeCart() {
|
|
document.body.style.overflow = '';
|
|
cartSidebar.classList.remove('translate-x-0');
|
|
cartSidebar.classList.add('translate-x-full');
|
|
setTimeout(() => {
|
|
cartBackdrop.classList.add('hidden');
|
|
}, 300);
|
|
}
|
|
|
|
// Stocker les fonctions dans une variable globale accessible par l'écouteur du document
|
|
window.openCart = openCart;
|
|
window.closeCart = closeCart;
|
|
} else {
|
|
// Sécurité si les éléments du panier n'existent pas
|
|
window.openCart = null;
|
|
window.closeCart = null;
|
|
}
|
|
|
|
|
|
// --- 3. Logique Panier Mock (Affichage du compteur) ---
|
|
function updateCartDisplay(count) {
|
|
const desktopCounter = document.getElementById('cartCountDesktop');
|
|
const mobileCounter = document.getElementById('cartCountMobile');
|
|
|
|
if (desktopCounter) desktopCounter.textContent = count;
|
|
if (mobileCounter) mobileCounter.textContent = count;
|
|
}
|
|
|
|
// Simuler un panier non-vide au chargement (Mettre 0 pour un panier vide réel)
|
|
updateCartDisplay(0);
|
|
|
|
// --- 4. Vérification de l'abonnement push au chargement (Logique demandée) ---
|
|
// Si la permission est déjà accordée, nous vérifions si l'abonnement est enregistré.
|
|
if ('Notification' in window && Notification.permission === 'granted') {
|
|
subscribeAndSave();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tente d'abonner l'utilisateur aux notifications push via le Service Worker
|
|
* et envoie l'objet d'abonnement au backend (/notificationSub).
|
|
*/
|
|
async function subscribeAndSave() {
|
|
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
|
console.log("Les notifications ne sont pas supportées ou la permission n'est pas accordée.");
|
|
return;
|
|
}
|
|
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
console.error("Le Service Worker ou PushManager n'est pas disponible pour l'abonnement.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
|
|
// 1. Si aucun abonnement n'existe, on essaie d'en créer un.
|
|
if (!subscription) {
|
|
console.log("Aucun abonnement existant trouvé. Tentative de nouvel abonnement...");
|
|
|
|
const applicationServerKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY);
|
|
|
|
subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: applicationServerKey
|
|
});
|
|
|
|
if (!subscription) {
|
|
console.error("Échec de la création de l'abonnement. Le Service Worker est-il correctement enregistré et la clé VAPID valide ?");
|
|
return;
|
|
}
|
|
} else {
|
|
console.log("Abonnement push existant trouvé. Vérification/Mise à jour auprès du serveur.");
|
|
}
|
|
|
|
// 2. Envoi (ou mise à jour) de l'abonnement au backend
|
|
const payload = { subscription: subscription.toJSON() };
|
|
|
|
const response = await fetch('/notificationSub', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
console.log("-> Abonnement aux notifications sauvegardé (ou vérifié) avec succès sur le serveur. <-");
|
|
} else {
|
|
console.error("-> Erreur lors de la sauvegarde de l'abonnement:", response.status, response.statusText);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("Erreur lors de l'obtention de l'abonnement ou de l'enregistrement:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tente de demander la permission si nécessaire et d'appeler l'abonnement.
|
|
* (Utilisée par le clic du bandeau)
|
|
*/
|
|
async function promptForPermissionAndSubscribe() {
|
|
if (!('Notification' in window)) {
|
|
console.error("Les Notifications ne sont pas supportées par ce navigateur.");
|
|
return;
|
|
}
|
|
|
|
// Demander la permission
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
|
|
if (permission === 'granted') {
|
|
console.log("-> Permission de notification accordée. Lancement de l'abonnement. <-");
|
|
await subscribeAndSave();
|
|
} else {
|
|
console.log(`-> Permission de notification refusée ou ignorée (${permission}). <-`);
|
|
}
|
|
} catch (error) {
|
|
console.error("Erreur lors de la demande de permission:", error);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Affiche une petite carte de notification push temporaire en bas à gauche.
|
|
* N'affiche QUE si la permission n'est PAS accordée.
|
|
*/
|
|
function handleNotificationBanner() {
|
|
// Clé pour éviter de ré-afficher le bandeau si l'utilisateur vient de le fermer/cliquer.
|
|
const BANNER_ID = 'notification-prompt-banner';
|
|
const DURATION_MS = 15000; // 15 secondes d'affichage
|
|
|
|
// 1. NE PAS AFFICHER si la permission est déjà accordée (la vérification silencieuse est dans initializeUI)
|
|
if ('Notification' in window && Notification.permission === 'granted') {
|
|
return;
|
|
}
|
|
|
|
// 2. Si le bandeau existe déjà (e.g. navigation rapide), on quitte.
|
|
if (document.getElementById(BANNER_ID)) {
|
|
return;
|
|
}
|
|
|
|
// --- LA LOGIQUE DE CONTRÔLE DE TEMPS (LOCAL STORAGE) A ÉTÉ RETIRÉE ICI ---
|
|
|
|
// 3. Créer le conteneur du message
|
|
const banner = document.createElement('div');
|
|
banner.id = BANNER_ID;
|
|
banner.className = `fixed bottom-4 left-4 z-50 p-4 max-w-xs
|
|
bg-indigo-600 text-white rounded-xl shadow-2xl
|
|
transition-all duration-500 transform
|
|
opacity-0 translate-y-full
|
|
md:left-8 md:bottom-8`; // Style initial (masqué)
|
|
|
|
banner.innerHTML = `
|
|
<div class="flex items-start justify-between">
|
|
<p class="font-semibold text-sm leading-snug">
|
|
🔔 Activer les notifications
|
|
</p>
|
|
<button id="closeNotificationBanner"
|
|
aria-label="Fermer la notification"
|
|
class="ml-3 -mt-1 p-1 rounded-full text-indigo-200 hover:text-white hover:bg-indigo-700 transition">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<p class="mt-1 text-xs text-indigo-200">
|
|
Recevez les nouvelles, les promotions et les événements de l'association.
|
|
</p>
|
|
<button id="activateNotifications"
|
|
class="mt-3 w-full text-center py-2 bg-white text-indigo-600 font-bold text-sm rounded-lg
|
|
shadow hover:bg-gray-100 transition transform hover:scale-[1.02]">
|
|
Activer
|
|
</button>
|
|
`;
|
|
|
|
document.body.appendChild(banner);
|
|
|
|
// 4. Fonctions d'animation et de gestion
|
|
const hideBanner = () => {
|
|
// Déclenche l'animation de disparition
|
|
banner.classList.remove('opacity-100', 'translate-y-0');
|
|
banner.classList.add('opacity-0', 'translate-y-full');
|
|
// Supprime après l'animation pour nettoyer le DOM
|
|
setTimeout(() => {
|
|
if (document.body.contains(banner)) {
|
|
document.body.removeChild(banner);
|
|
}
|
|
}, 600);
|
|
};
|
|
|
|
// Clic sur le bouton de fermeture
|
|
document.getElementById('closeNotificationBanner').addEventListener('click', () => {
|
|
hideBanner();
|
|
});
|
|
|
|
// Clic sur le bouton d'activation -> Logique Push
|
|
document.getElementById('activateNotifications').addEventListener('click', async () => {
|
|
await promptForPermissionAndSubscribe();
|
|
// Fermer le bandeau après l'interaction (que ce soit accordé ou refusé)
|
|
hideBanner();
|
|
});
|
|
|
|
// 5. Affichage et Timer
|
|
|
|
// Montre le bandeau (déclencher l'animation)
|
|
setTimeout(() => {
|
|
banner.classList.remove('opacity-0', 'translate-y-full');
|
|
banner.classList.add('opacity-100', 'translate-y-0');
|
|
}, 100);
|
|
|
|
// Cache le bandeau automatiquement après la durée définie
|
|
setTimeout(hideBanner, DURATION_MS);
|
|
}
|
|
|
|
|
|
// --- INITIALISATION DES COMPOSANTS APRÈS TURBO/CHARGEMENT ---
|
|
document.addEventListener('DOMContentLoaded', ()=>{
|
|
customElements.define('payment-don',PaymentForm,{extends:'form'})
|
|
|
|
// initializeUI appelle subscribeAndSave si la permission est accordée
|
|
initializeUI()
|
|
if (typeof navigator.serviceWorker !== 'undefined') {
|
|
// Assurez-vous que le Service Worker est bien enregistré en mode prod
|
|
navigator.serviceWorker.register('sw.js')
|
|
}
|
|
// handleNotificationBanner n'affiche que si la permission n'est PAS accordée
|
|
handleNotificationBanner()
|
|
|
|
});
|
|
document.addEventListener('turbo:load', initializeUI);
|
|
|
|
|
|
// ====================================================================
|
|
// --- DÉLÉGATION D'ÉVÉNEMENTS (Gestion des clics une seule fois) ---
|
|
// ====================================================================
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const target = event.target;
|
|
|
|
// --- 1. GESTION DU MENU MOBILE (Burger) ---
|
|
const mobileMenuButton = document.getElementById('mobileMenuButton');
|
|
const mobileMenu = document.getElementById('mobile-menu');
|
|
|
|
// On vérifie si la cible cliquée est le bouton ou un de ses enfants
|
|
if (mobileMenuButton && mobileMenu && (target === mobileMenuButton || mobileMenuButton.contains(target))) {
|
|
event.preventDefault();
|
|
toggleMenu(mobileMenuButton, mobileMenu);
|
|
return;
|
|
}
|
|
|
|
// --- 2. GESTION DU MENU UTILISATEUR (Dropdown) ---
|
|
const userMenuButtonDesktop = document.getElementById('userMenuButtonDesktop');
|
|
const userMenuDesktop = document.getElementById('userMenuDesktop');
|
|
const userMenuButtonMobile = document.getElementById('userMenuButtonMobile');
|
|
const userMenuMobile = document.getElementById('userMenuMobile');
|
|
|
|
// Ouverture/Fermeture du menu utilisateur Desktop
|
|
if (userMenuButtonDesktop && userMenuDesktop && (target === userMenuButtonDesktop || userMenuButtonDesktop.contains(target))) {
|
|
event.preventDefault();
|
|
// S'assurer que les autres menus sont fermés
|
|
userMenuMobile.classList.add('hidden');
|
|
toggleMenu(userMenuButtonDesktop, userMenuDesktop);
|
|
return;
|
|
}
|
|
|
|
// Ouverture/Fermeture du menu utilisateur Mobile
|
|
if (userMenuButtonMobile && userMenuMobile && (target === userMenuButtonMobile || userMenuButtonMobile.contains(target))) {
|
|
event.preventDefault();
|
|
// S'assurer que les autres menus sont fermés
|
|
userMenuDesktop.classList.add('hidden');
|
|
toggleMenu(userMenuButtonMobile, userMenuMobile);
|
|
return;
|
|
}
|
|
|
|
// Fermeture des menus s'il y a un clic en dehors
|
|
const isClickInsideDesktopMenu = userMenuDesktop && (userMenuDesktop.contains(target) || userMenuButtonDesktop.contains(target));
|
|
const isClickInsideMobileMenu = userMenuMobile && (userMenuMobile.contains(target) || userMenuButtonMobile.contains(target));
|
|
|
|
if (userMenuDesktop && userMenuButtonDesktop && !isClickInsideDesktopMenu) {
|
|
userMenuDesktop.classList.add('hidden');
|
|
userMenuButtonDesktop.setAttribute('aria-expanded', 'false');
|
|
}
|
|
if (userMenuMobile && userMenuButtonMobile && !isClickInsideMobileMenu) {
|
|
userMenuMobile.classList.add('hidden');
|
|
userMenuButtonMobile.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
// --- 3. GESTION DE L'OUVERTURE ET FERMETURE DU PANIER ---
|
|
const openCartDesktop = document.getElementById('openCartDesktop');
|
|
const openCartMobile = document.getElementById('openCartMobile');
|
|
const closeCartButton = document.getElementById('closeCartButton');
|
|
const cartBackdrop = document.getElementById('cartBackdrop');
|
|
|
|
// Ouverture (Desktop ou Mobile)
|
|
if (window.openCart && (
|
|
(openCartDesktop && (target === openCartDesktop || openCartDesktop.contains(target))) ||
|
|
(openCartMobile && (target === openCartMobile || openCartMobile.contains(target)))
|
|
)) {
|
|
event.preventDefault();
|
|
window.openCart();
|
|
return;
|
|
}
|
|
|
|
// Fermeture (Bouton interne)
|
|
if (window.closeCart && closeCartButton && (target === closeCartButton || closeCartButton.contains(target))) {
|
|
event.preventDefault();
|
|
window.closeCart();
|
|
return;
|
|
}
|
|
|
|
// Fermeture (Cliquer sur le fond/backdrop)
|
|
if (window.closeCart && target === cartBackdrop) {
|
|
window.closeCart();
|
|
return;
|
|
}
|
|
|
|
});
|
|
|
|
// --- GESTION GLOBALE DE LA TOUCHE ESC (Une seule fois) ---
|
|
document.addEventListener('keydown', (event) => {
|
|
const cartSidebar = document.getElementById('cartSidebar');
|
|
// Fermer le panier
|
|
if (cartSidebar && window.closeCart && event.key === 'Escape' && !cartSidebar.classList.contains('translate-x-full')) {
|
|
window.closeCart();
|
|
return;
|
|
}
|
|
|
|
// Fermer les menus utilisateur
|
|
const userMenuDesktop = document.getElementById('userMenuDesktop');
|
|
const userMenuButtonDesktop = document.getElementById('userMenuButtonDesktop');
|
|
const userMenuMobile = document.getElementById('userMenuMobile');
|
|
const userMenuButtonMobile = document.getElementById('userMenuButtonMobile');
|
|
|
|
if (event.key === 'Escape') {
|
|
if (userMenuDesktop && !userMenuDesktop.classList.contains('hidden')) {
|
|
toggleMenu(userMenuButtonDesktop, userMenuDesktop);
|
|
return;
|
|
}
|
|
if (userMenuMobile && !userMenuMobile.classList.contains('hidden')) {
|
|
toggleMenu(userMenuButtonMobile, userMenuMobile);
|
|
return;
|
|
}
|
|
}
|
|
});
|