From 127b75f8eac50b45f99d3f2c18eead75080daf54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 04:56:47 +0000 Subject: [PATCH] =?UTF-8?q?Signale=20aux=20membres=20qu'ils=20ne=20re?= =?UTF-8?q?=C3=A7oivent=20pas=20les=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un parent qui n'a jamais activé les notifications ne s'en rend pas compte : il croit simplement que l'association ne lui écrit pas. Un bandeau le lui dit en haut du tableau de bord, et propose l'activation en un clic, sans passer par les réglages. Il apparaît dès que le membre ne recevrait rien — appareil jamais activé, ou notifications coupées sur le compte — et disparaît sitôt les deux réunis. « Plus tard » le range pour un mois : assez pour ne pas harceler celui qui refuse, assez court pour rattraper celui qui remet à plus tard. Sur iPhone, où Safari n'ouvre les notifications qu'aux sites ajoutés à l'écran d'accueil, le bandeau explique la manipulation au lieu de proposer un bouton qui ne pourrait pas fonctionner. Il s'efface sur « Mon compte », où le réglage complet se trouve déjà, et pendant le guide de première connexion, pour ne pas disputer l'attention d'un membre qui découvre l'application. La logique d'abonnement, jusqu'ici enfermée dans le réglage du compte, est extraite dans un module partagé : une seule demande de permission, un seul enregistrement, deux points d'entrée qui ne peuvent plus diverger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- src/app/dashboard/layout.tsx | 6 +- src/components/push-banner.tsx | 208 ++++++++++++++++++++++++++++++++ src/components/push-toggle.tsx | 96 +++------------ src/components/welcome-tour.tsx | 13 ++ src/lib/push-device.ts | 132 ++++++++++++++++++++ 5 files changed, 375 insertions(+), 80 deletions(-) create mode 100644 src/components/push-banner.tsx create mode 100644 src/lib/push-device.ts 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 ? ( + + En savoir plus + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/components/push-toggle.tsx b/src/components/push-toggle.tsx index 8946f02..d700e5a 100644 --- a/src/components/push-toggle.tsx +++ b/src/components/push-toggle.tsx @@ -5,6 +5,13 @@ import { useCallback, useEffect, useState } from "react"; import { useToast } from "@/components/toast"; import { api } from "@/lib/client"; +import { + abonnementLocal, + activerCetAppareil, + desactiverCetAppareil, + pushDisponible, + signalerChangementPush, +} from "@/lib/push-device"; /** * Notifications sur appareil : deux niveaux, volontairement distincts. @@ -15,37 +22,6 @@ import { api } from "@/lib/client"; * son téléphone sans l'être sur l'ordinateur familial. */ -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))); -} - -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" - : "Appareil"; - const navigateur = /Edg\//.test(ua) - ? "Edge" - : /Chrome\//.test(ua) - ? "Chrome" - : /Firefox\//.test(ua) - ? "Firefox" - : /Safari\//.test(ua) - ? "Safari" - : "navigateur"; - return `${systeme} · ${navigateur}`; -} - export function PushToggle({ enabled }: { enabled: boolean }) { const toast = useToast(); const [compteActif, setCompteActif] = useState(enabled); @@ -54,16 +30,10 @@ export function PushToggle({ enabled }: { enabled: boolean }) { const [supporte, setSupporte] = useState(true); useEffect(() => { - const ok = - typeof window !== "undefined" && - "serviceWorker" in navigator && - "PushManager" in window && - "Notification" in window; + const ok = pushDisponible(); setSupporte(ok); if (!ok) return setAbonne(false); - navigator.serviceWorker - .getRegistration() - .then((r) => r?.pushManager.getSubscription() ?? null) + abonnementLocal() .then((s) => setAbonne(Boolean(s))) .catch(() => setAbonne(false)); }, []); @@ -71,36 +41,9 @@ export function PushToggle({ enabled }: { enabled: boolean }) { const activerAppareil = useCallback(async () => { setOccupe(true); try { - 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(), - }, - }); + await activerCetAppareil(); setAbonne(true); + signalerChangementPush({ compteActif: true }); toast("Cet appareil recevra les notifications."); } catch (error) { toast((error as Error).message, "error"); @@ -112,28 +55,22 @@ export function PushToggle({ enabled }: { enabled: boolean }) { const desactiverAppareil = useCallback(async () => { setOccupe(true); try { - const registration = await navigator.serviceWorker.getRegistration(); - const subscription = await registration?.pushManager.getSubscription(); - if (subscription) { - await api("/api/push/subscribe", { - method: "DELETE", - body: { endpoint: subscription.endpoint }, - }); - await subscription.unsubscribe(); - } + await desactiverCetAppareil(); setAbonne(false); + signalerChangementPush({ compteActif }); toast("Cet appareil ne recevra plus de notifications."); } catch (error) { toast((error as Error).message, "error"); } finally { setOccupe(false); } - }, [toast]); + }, [toast, compteActif]); async function changerCompte(valeur: boolean) { setCompteActif(valeur); try { await api("/api/me", { method: "PATCH", body: { pushEnabled: valeur } }); + signalerChangementPush({ compteActif: valeur }); toast( valeur ? "Vous recevrez de nouveau les notifications." @@ -168,7 +105,8 @@ export function PushToggle({ enabled }: { enabled: boolean }) { {!supporte ? (

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(); +}