diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index b53eaa4..578bff4 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -1,4 +1,5 @@
import { DashboardNav } from "@/components/dashboard-nav";
+import { PushBanner } from "@/components/push-banner";
import { WelcomeTour } from "@/components/welcome-tour";
import { requireUser } from "@/lib/auth/rbac";
import { getAssociationSettings } from "@/lib/services/association-settings";
@@ -23,7 +24,10 @@ export default async function DashboardLayout({
user={{ name: user.name, role: user.role }}
/>
-
{children}
+
+
+ {children}
+
diff --git a/src/components/push-banner.tsx b/src/components/push-banner.tsx
new file mode 100644
index 0000000..44ed188
--- /dev/null
+++ b/src/components/push-banner.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { BellRing, Loader2, Share, X } from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+
+import { useToast } from "@/components/toast";
+import { GUIDE_OUVERT_EVENT } from "@/components/welcome-tour";
+import { api } from "@/lib/client";
+import {
+ abonnementLocal,
+ activerCetAppareil,
+ EVENEMENT_PUSH,
+ iosSansEcranAccueil,
+ pushDisponible,
+ signalerChangementPush,
+} from "@/lib/push-device";
+
+/**
+ * Bandeau affiché aux membres qui ne recevront rien.
+ *
+ * Un parent qui n'a jamais activé les notifications ne le sait pas : il croit
+ * simplement que l'association ne lui écrit pas. Le bandeau le lui dit là où il
+ * regarde, et propose l'activation en un clic — sans passer par les réglages.
+ *
+ * Il se ferme pour un mois : assez pour ne pas harceler celui qui refuse,
+ * assez court pour rattraper celui qui remet à plus tard.
+ */
+
+const CLE_REPORT = "apel:bandeau-notifications-reporte-jusquau";
+const DUREE_REPORT_MS = 30 * 24 * 60 * 60 * 1000;
+
+type Etat = "chargement" | "masque" | "a-activer" | "ios";
+
+function reporte(): boolean {
+ try {
+ const valeur = window.localStorage.getItem(CLE_REPORT);
+ return valeur !== null && Number(valeur) > Date.now();
+ } catch {
+ return false;
+ }
+}
+
+function reporter() {
+ try {
+ window.localStorage.setItem(
+ CLE_REPORT,
+ String(Date.now() + DUREE_REPORT_MS),
+ );
+ } catch {
+ // Navigation privée ou stockage refusé : le bandeau reviendra, tant pis.
+ }
+}
+
+export function PushBanner({ accountEnabled }: { accountEnabled: boolean }) {
+ const toast = useToast();
+ const chemin = usePathname();
+ const [etat, setEtat] = useState("chargement");
+ const [compteActif, setCompteActif] = useState(accountEnabled);
+ const [occupe, setOccupe] = useState(false);
+ const [guideOuvert, setGuideOuvert] = useState(false);
+
+ const evaluer = useCallback(async (actif: boolean) => {
+ if (reporte()) return setEtat("masque");
+ if (!pushDisponible()) {
+ return setEtat(iosSansEcranAccueil() ? "ios" : "masque");
+ }
+ const abonnement = await abonnementLocal().catch(() => null);
+ // Il faut les deux : un appareil abonné ne reçoit rien si le compte a coupé.
+ setEtat(abonnement && actif ? "masque" : "a-activer");
+ }, []);
+
+ useEffect(() => {
+ void evaluer(compteActif);
+ }, [evaluer, compteActif]);
+
+ // La page « Mon compte » garde l'état à jour de son côté : le bandeau, lui,
+ // survit aux navigations et n'apprendrait rien sans ce signal.
+ useEffect(() => {
+ function surChangement(event: Event) {
+ const detail = (event as CustomEvent<{ compteActif?: boolean }>).detail;
+ if (typeof detail?.compteActif === "boolean") {
+ setCompteActif(detail.compteActif);
+ }
+ void evaluer(detail?.compteActif ?? compteActif);
+ }
+ window.addEventListener(EVENEMENT_PUSH, surChangement);
+ return () => window.removeEventListener(EVENEMENT_PUSH, surChangement);
+ }, [evaluer, compteActif]);
+
+ // Le guide de première connexion couvre l'écran : inutile d'y superposer une
+ // invitation de plus, elle attendra qu'il soit terminé.
+ useEffect(() => {
+ function surGuide(event: Event) {
+ const detail = (event as CustomEvent<{ ouvert?: boolean }>).detail;
+ setGuideOuvert(Boolean(detail?.ouvert));
+ }
+ window.addEventListener(GUIDE_OUVERT_EVENT, surGuide);
+ return () => window.removeEventListener(GUIDE_OUVERT_EVENT, surGuide);
+ }, []);
+
+ async function activer() {
+ setOccupe(true);
+ try {
+ // Le clic vaut consentement : si le compte avait tout coupé, on le
+ // rallume plutôt que de renvoyer le membre vers ses réglages.
+ if (!compteActif) {
+ await api("/api/me", { method: "PATCH", body: { pushEnabled: true } });
+ setCompteActif(true);
+ }
+ await activerCetAppareil();
+ signalerChangementPush({ compteActif: true });
+ setEtat("masque");
+ toast("Cet appareil recevra les notifications de l’association.");
+ } catch (error) {
+ toast((error as Error).message, "error");
+ } finally {
+ setOccupe(false);
+ }
+ }
+
+ function plusTard() {
+ reporter();
+ setEtat("masque");
+ }
+
+ // Sur « Mon compte », le réglage complet est juste en dessous : un bandeau
+ // qui répète l'invitation ferait doublon.
+ if (chemin === "/dashboard/account") return null;
+ if (guideOuvert) return null;
+ if (etat === "chargement" || etat === "masque") return null;
+
+ const iOS = etat === "ios";
+
+ return (
+
+ {/* Icône et texte forment un seul bloc : sur un téléphone, il passe en
+ entier à la ligne au lieu de s'étrangler à côté de l'icône. */}
+
+ {iOS ? (
+
+ ) : (
+
+ )}
+
+
+ {iOS
+ ? "Recevez les notifications sur votre iPhone"
+ : "Vous ne recevez pas les notifications"}
+
+
+ {iOS ? (
+ <>
+ Touchez {" "}
+ Partager en bas de Safari, puis « Sur l’écran d’accueil ».
+ Ouvrez ensuite le site depuis cette icône pour les activer.
+ >
+ ) : (
+ "Les messages de l’association arrivent directement sur cet appareil, même quand le site est fermé."
+ )}
+
+
+
+
+ {iOS ? (
+
+ En savoir plus
+
+ ) : (
+
+ )}
+
+
Ce navigateur ne gère pas les notifications. Sur iPhone, ajoutez
- d’abord le site à l’écran d’accueil depuis Safari.
+ d’abord le site à l’écran d’accueil depuis Safari : touchez Partager,
+ puis « Sur l’écran d’accueil », et rouvrez le site depuis cette icône.
) : (
diff --git a/src/components/welcome-tour.tsx b/src/components/welcome-tour.tsx
index 3ecf9ab..23367df 100644
--- a/src/components/welcome-tour.tsx
+++ b/src/components/welcome-tour.tsx
@@ -92,6 +92,13 @@ function cibleVisible(href: string): HTMLElement | null {
/** Événement à émettre pour relancer le guide depuis n'importe quel écran. */
export const TOUR_EVENT = "apel:relancer-guide";
+/**
+ * Émis à chaque ouverture ou fermeture du guide : les autres invitations du
+ * tableau de bord s'effacent le temps qu'il dure, pour ne pas se disputer
+ * l'attention d'un membre qui découvre l'application.
+ */
+export const GUIDE_OUVERT_EVENT = "apel:guide-ouvert";
+
export function WelcomeTour({
autoStart,
onFinished,
@@ -136,6 +143,12 @@ export function WelcomeTour({
if (ouvert) carte.current?.focus();
}, [ouvert, index]);
+ useEffect(() => {
+ window.dispatchEvent(
+ new CustomEvent(GUIDE_OUVERT_EVENT, { detail: { ouvert } }),
+ );
+ }, [ouvert]);
+
useEffect(() => {
function relancer() {
setIndex(0);
diff --git a/src/lib/push-device.ts b/src/lib/push-device.ts
new file mode 100644
index 0000000..48865a5
--- /dev/null
+++ b/src/lib/push-device.ts
@@ -0,0 +1,132 @@
+import { api } from "@/lib/client";
+
+/**
+ * Abonnement push d'un appareil, côté navigateur.
+ *
+ * Partagé par le réglage « Mon compte » et par la bannière du tableau de bord :
+ * un seul chemin de code demande la permission, enregistre le service worker
+ * et déclare l'abonnement au serveur, pour que les deux entrées ne puissent pas
+ * diverger.
+ */
+
+/** Émis sur `window` dès qu'un appareil s'abonne, se désabonne ou coupe tout. */
+export const EVENEMENT_PUSH = "apel:push-change";
+
+type DetailPush = { compteActif?: boolean };
+
+export function signalerChangementPush(detail: DetailPush = {}) {
+ window.dispatchEvent(new CustomEvent(EVENEMENT_PUSH, { detail }));
+}
+
+export function pushDisponible(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ "serviceWorker" in navigator &&
+ "PushManager" in window &&
+ "Notification" in window
+ );
+}
+
+/**
+ * iPhone et iPad n'ouvrent les notifications qu'aux sites ajoutés à l'écran
+ * d'accueil : dans Safari, l'API n'existe pas du tout. On sait donc distinguer
+ * « ce navigateur ne saura jamais » d'« il manque une étape à l'utilisateur ».
+ */
+export function iosSansEcranAccueil(): boolean {
+ if (typeof window === "undefined" || pushDisponible()) return false;
+ const ua = navigator.userAgent;
+ const ios =
+ /iPhone|iPad|iPod/.test(ua) ||
+ // iPadOS se fait passer pour un Mac, seul l'écran tactile le trahit.
+ (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
+ const installe =
+ window.matchMedia("(display-mode: standalone)").matches ||
+ (navigator as Navigator & { standalone?: boolean }).standalone === true;
+ return ios && !installe;
+}
+
+export async function abonnementLocal(): Promise {
+ if (!pushDisponible()) return null;
+ const registration = await navigator.serviceWorker.getRegistration();
+ return (await registration?.pushManager.getSubscription()) ?? null;
+}
+
+function base64UrlVersUint8(base64: string) {
+ const rembourre = (base64 + "=".repeat((4 - (base64.length % 4)) % 4))
+ .replace(/-/g, "+")
+ .replace(/_/g, "/");
+ const brut = atob(rembourre);
+ return Uint8Array.from([...brut].map((c) => c.charCodeAt(0)));
+}
+
+/** Étiquette lisible pour que le membre reconnaisse ses appareils dans la liste. */
+export function nomAppareil() {
+ const ua = navigator.userAgent;
+ const systeme = /Android/i.test(ua)
+ ? "Android"
+ : /iPhone|iPad|iPod/i.test(ua)
+ ? "iOS"
+ : /Mac/i.test(ua)
+ ? "Mac"
+ : /Windows/i.test(ua)
+ ? "Windows"
+ : /Linux/i.test(ua)
+ ? "Linux"
+ : "Appareil";
+ const navigateur = /Edg\//.test(ua)
+ ? "Edge"
+ : /Chrome\//.test(ua)
+ ? "Chrome"
+ : /Firefox\//.test(ua)
+ ? "Firefox"
+ : /Safari\//.test(ua)
+ ? "Safari"
+ : "navigateur";
+ return `${systeme} · ${navigateur}`;
+}
+
+/**
+ * Abonne l'appareil courant. À n'appeler que depuis un clic : les navigateurs
+ * refusent une demande de permission qui ne suit pas un geste de l'utilisateur.
+ */
+export async function activerCetAppareil(): Promise {
+ const permission = await Notification.requestPermission();
+ if (permission !== "granted") {
+ throw new Error(
+ "Votre navigateur a refusé les notifications. Autorisez-les dans ses réglages, puis réessayez.",
+ );
+ }
+ const registration = await navigator.serviceWorker.register("/sw.js");
+ await navigator.serviceWorker.ready;
+ const { publicKey } = (await fetch("/api/push/key", {
+ cache: "no-store",
+ }).then((r) => r.json())) as { publicKey: string | null };
+ if (!publicKey) throw new Error("Clé du serveur indisponible.");
+
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: base64UrlVersUint8(publicKey),
+ });
+ const brut = subscription.toJSON() as {
+ endpoint: string;
+ keys: { p256dh: string; auth: string };
+ };
+ await api("/api/push/subscribe", {
+ body: {
+ endpoint: brut.endpoint,
+ p256dh: brut.keys.p256dh,
+ auth: brut.keys.auth,
+ deviceLabel: nomAppareil(),
+ },
+ });
+}
+
+export async function desactiverCetAppareil(): Promise {
+ const subscription = await abonnementLocal();
+ if (!subscription) return;
+ await api("/api/push/subscribe", {
+ method: "DELETE",
+ body: { endpoint: subscription.endpoint },
+ });
+ await subscription.unsubscribe();
+}