Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,7 +24,10 @@ export default async function DashboardLayout({
user={{ name: user.name, role: user.role }}
/>
<main className="flex-1 px-4 py-6 sm:px-7 sm:py-8 lg:h-screen lg:overflow-y-auto lg:px-10 lg:py-10 xl:px-12">
<div className="mx-auto max-w-7xl">{children}</div>
<div className="mx-auto max-w-7xl space-y-6">
<PushBanner accountEnabled={user.pushEnabled} />
{children}
</div>
</main>
<WelcomeTour autoStart={user.onboardingSeenAt === null} />
</div>
Expand Down
208 changes: 208 additions & 0 deletions src/components/push-banner.tsx
Original file line number Diff line number Diff line change
@@ -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<Etat>("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 (
<div
role="status"
className="flex flex-wrap items-center gap-x-4 gap-y-3 rounded-2xl border-2 border-brand-200 bg-brand-50 px-4 py-3 sm:px-5"
>
{/* 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. */}
<div className="flex min-w-0 flex-1 basis-72 items-start gap-3">
{iOS ? (
<Share
className="mt-0.5 h-5 w-5 shrink-0 text-brand-600"
aria-hidden="true"
/>
) : (
<BellRing
className="mt-0.5 h-5 w-5 shrink-0 text-brand-600"
aria-hidden="true"
/>
)}
<p className="min-w-0 text-sm leading-6 text-brand-900">
<span className="font-bold">
{iOS
? "Recevez les notifications sur votre iPhone"
: "Vous ne recevez pas les notifications"}
</span>
<span className="block text-brand-800">
{iOS ? (
<>
Touchez <Share className="inline h-4 w-4" aria-hidden="true" />{" "}
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é."
)}
</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{iOS ? (
<Link
href="/dashboard/account"
className="inline-flex min-h-10 items-center rounded-xl border-2 border-brand-200 bg-white px-3.5 py-2 text-sm font-bold text-brand-900 transition-colors hover:border-brand-300"
>
En savoir plus
</Link>
) : (
<button
type="button"
onClick={() => void activer()}
disabled={occupe}
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-brand-600 px-3.5 py-2 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{occupe ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<BellRing className="h-4 w-4" aria-hidden="true" />
)}
Activer les notifications
</button>
)}
<button
type="button"
onClick={plusTard}
className="inline-flex min-h-10 items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-semibold text-brand-800 transition-colors hover:bg-brand-100"
>
<X className="h-4 w-4" aria-hidden="true" />
Plus tard
</button>
</div>
</div>
);
}
96 changes: 17 additions & 79 deletions src/components/push-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -54,53 +30,20 @@ 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));
}, []);

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");
Expand All @@ -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."
Expand Down Expand Up @@ -168,7 +105,8 @@ export function PushToggle({ enabled }: { enabled: boolean }) {
{!supporte ? (
<p className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-500">
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.
</p>
) : (
<div className="flex flex-wrap items-center gap-3 rounded-xl border-2 border-slate-200 bg-slate-50 px-4 py-3">
Expand Down
Loading
Loading