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