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.
```
This commit is contained in:
Serreau Jovann
2025-11-19 13:48:31 +01:00
parent 1d97514c94
commit de9c951eaf
9 changed files with 410 additions and 22 deletions

2
.env
View File

@@ -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

View File

@@ -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;"
}

View File

@@ -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 = `
<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()
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);

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251119124756 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

BIN
public/assets/notif.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -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);
})
);
});

View File

@@ -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
{

51
src/Entity/Sub.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace App\Entity;
use App\Repository\SubRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SubRepository::class)]
class Sub
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::ARRAY)]
private array $subcriber = [];
#[ORM\Column(length: 255, nullable: true)]
private ?string $subId = null;
public function getId(): ?int
{
return $this->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;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Sub;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Sub>
*/
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()
// ;
// }
}