diff --git a/.env b/.env index b691114..63c6af5 100644 --- a/.env +++ b/.env @@ -56,3 +56,5 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR STRIPE_WEBHOOKS_SIGN=whsec_0DOZJAwgMwkcHl2RWXI8h8YItj9q7v3A DEV_URL=https://3ea1cf1b1555.ngrok-free.app +VAPID_PK=DsOg7jToRSD-VpNSV1Gt3YAhSwz4l-nqeu7yFvzbSxg +VAPID_PC=BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo diff --git a/ansible/templates/caddy.j2 b/ansible/templates/caddy.j2 index 48eb368..f8e8517 100644 --- a/ansible/templates/caddy.j2 +++ b/ansible/templates/caddy.j2 @@ -14,7 +14,7 @@ www.e-cosplay.fr { header { -X-Robots-Tag - Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-write=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), publickey-credentials-get=(), usb=(), vr=(), screen-wake-lock=(), xr-spatial-tracking=(), bluetooth=(), ambient-light-sensor=(), battery=(), gamepad=(), notifications=(), push=()" + Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-write=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), publickey-credentials-get=(), usb=(), screen-wake-lock=(), xr-spatial-tracking=(), bluetooth=(), gamepad=()" Content-Security-Policy "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' https://datas.e-cosplay.fr https://*.cloudflareinsights.com https://storage.googleapis.com https://*.trustpilot.com; font-src 'self' https://fonts.gstatic.com;connect-src https://*.e-cosplay.fr https://*.cloudflareinsights.com https://fonts.googleapis.com https://widget.trustpilot.com/ https://challenges.cloudflare.com; frame-src 'self' https://*.trustpilot.com;" } diff --git a/assets/app.js b/assets/app.js index 8161da6..0c73a18 100644 --- a/assets/app.js +++ b/assets/app.js @@ -2,6 +2,34 @@ 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. @@ -76,19 +104,202 @@ function initializeUI() { // 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 = ` +
+

+ 🔔 Activer les notifications +

+ +
+

+ Recevez les nouvelles, les promotions et les événements de l'association. +

+ + `; + + 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() - const env = document.querySelector('meta[name="env"]') - if(env.getAttribute('content') == "prod") { - if (typeof navigator.serviceWorker !== 'undefined') { - navigator.serviceWorker.register('sw.js') - } + 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); diff --git a/migrations/Version20251119124756.php b/migrations/Version20251119124756.php new file mode 100644 index 0000000..e76e71b --- /dev/null +++ b/migrations/Version20251119124756.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE sub (id SERIAL NOT NULL, subcriber TEXT NOT NULL, sub_id VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN sub.subcriber IS \'(DC2Type:array)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE sub'); + } +} diff --git a/public/assets/notif.png b/public/assets/notif.png new file mode 100644 index 0000000..50844d3 Binary files /dev/null and b/public/assets/notif.png differ diff --git a/public/sw.js b/public/sw.js index 8a0c7f9..ac6a23f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -31,24 +31,54 @@ workbox.routing.registerRoute( }) ); -self.addEventListener('fetch', (event) => { - if (event.request.mode === 'navigate') { - event.respondWith((async () => { - try { - const preloadResp = await event.preloadResponse; +// --- GESTION DES NOTIFICATIONS PUSH (Réception) --- +self.addEventListener('push', (event) => { + if (event.data) { + // Assurez-vous que le payload JSON envoyé par votre serveur contient + // 'title', 'message' et 'link'. + const data = event.data.json(); - if (preloadResp) { - return preloadResp; - } + const title = data.title || 'Nouvelle Notification'; + const message = data.message || 'Contenu mis à jour.'; + const link = data.link || '/'; // Lien par défaut vers la racine - const networkResp = await fetch(event.request); - return networkResp; - } catch (error) { - - const cache = await caches.open(CACHE); - const cachedResp = await cache.match(offlineFallbackPage); - return cachedResp; + const options = { + body: message, + // PATH MIS À JOUR ICI + icon: data.icon || '/assets/notif.png', + data: { + link: link // On stocke le lien pour le réutiliser au clic } - })()); + }; + + // Affiche la notification + event.waitUntil( + self.registration.showNotification(title, options) + ); } }); + +// --- GESTION DES CLICS SUR LA NOTIFICATION --- +self.addEventListener('notificationclick', (event) => { + // Récupère le lien stocké dans la notification + const urlToOpen = event.notification.data.link || '/'; + + // Ferme la notification après le clic + event.notification.close(); + + // Ouvre l'URL associée, soit dans un onglet existant, soit dans un nouvel onglet + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + + // Tente de trouver un client existant pour naviguer + for (const client of clientList) { + if (client.url.endsWith(urlToOpen) && 'focus' in client) { + return client.focus(); + } + } + + // Sinon, ouvre une nouvelle fenêtre/onglet + return clients.openWindow(urlToOpen); + }) + ); +}); diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 1a1d296..20ba4a0 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -4,8 +4,10 @@ namespace App\Controller; use App\Entity\Account; use App\Entity\AccountResetPasswordRequest; +use App\Entity\Sub; use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; +use App\Repository\SubRepository; use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; use App\Service\ResetPassword\Event\ResetPasswordEvent; use Doctrine\ORM\EntityManagerInterface; @@ -23,6 +25,22 @@ use Twig\Environment; class SecurityController extends AbstractController { + #[Route(path: '/notificationSub', name: 'app_notificationSub', options: ['sitemap' => false], methods: ['POST'])] + public function notificationSub(Request $request,SubRepository $subRepository,EntityManagerInterface $entityManager): Response + { + $content = json_decode($request->getContent(),true); + + $sub = $subRepository->findOneBy(['subId'=>$content['subscription']['endpoint']]); + if(!$sub instanceof Sub){ + $sub = new Sub(); + $sub->setSubId($content['subscription']['endpoint']); + $sub->setSubcriber($content); + $entityManager->persist($sub); + } + $entityManager->flush(); + return $this->json([]); + } + #[Route(path: '/connexion', name: 'app_login', options: ['sitemap' => false], methods: ['GET','POST'])] public function login(AuthenticationUtils $authenticationUtils): Response { diff --git a/src/Entity/Sub.php b/src/Entity/Sub.php new file mode 100644 index 0000000..5fdebab --- /dev/null +++ b/src/Entity/Sub.php @@ -0,0 +1,51 @@ +id; + } + + public function getSubcriber(): array + { + return $this->subcriber; + } + + public function setSubcriber(array $subcriber): static + { + $this->subcriber = $subcriber; + + return $this; + } + + public function getSubId(): ?string + { + return $this->subId; + } + + public function setSubId(?string $subId): static + { + $this->subId = $subId; + + return $this; + } +} diff --git a/src/Repository/SubRepository.php b/src/Repository/SubRepository.php new file mode 100644 index 0000000..d232455 --- /dev/null +++ b/src/Repository/SubRepository.php @@ -0,0 +1,43 @@ + + */ +class SubRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Sub::class); + } + + // /** + // * @return Sub[] Returns an array of Sub objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Sub + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +}