Aggiunge il centro notifiche in-app accanto al profilo

Icona a campana con badge delle non lette, indipendente dal canale push:
copre messaggio libero admin, promemoria evento (24h/3h prima), turno
palloni e sollecito presenze. Ogni notifica si rimuove con uno swipe o
la ×, non c'è pulizia automatica delle vecchie (DD-030).

Migration m17_notifiche_utente non ancora applicata in produzione.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
This commit is contained in:
davideandClaude Mythos committed 2026-09-15 11:19:07 +02:00
1 parent 0314560885
commit 4d036f532d
15 files changed
+1184 -11

No files matched your search

+158 -3
View File
@@ -1,10 +1,27 @@
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { Bell, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useMotoRidotto } from "@/lib/motion";
import { molla, proietta } from "@/lib/molla";
import {
contatoreBadge,
useEliminaNotifica,
useNotificheMie,
useSegnaLette,
type NotificaUtente,
} from "@/lib/notifiche-utente";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
@@ -72,6 +89,139 @@ export function LinkProfilo() {
);
}
/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
function tempoRelativo(dataIso: string): string {
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
const ore = Math.round(minuti / 60);
if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
return rtf.format(Math.round(ore / 24), "day");
}
/**
* Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
* c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
* giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
* lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
* c'è "indietro", solo "via".
*/
function RigaNotifica({
notifica,
onElimina,
}: {
notifica: NotificaUtente;
onElimina: (id: string) => void;
}) {
const ridotto = useMotoRidotto();
return (
<motion.li
layout={!ridotto}
initial={false}
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
transition={molla.ui}
className="relative overflow-hidden rounded-xl"
>
<motion.div
drag={ridotto ? false : "x"}
dragConstraints={{ left: -80, right: 0 }}
dragElastic={{ left: 0.2, right: 0 }}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) onElimina(notifica.id);
}}
className="touch-pan-y bg-card p-3 pr-8 text-sm"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? <p className="mt-0.5 text-muted-foreground">{notifica.corpo}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
type="button"
onClick={() => onElimina(notifica.id)}
aria-label="Rimuovi notifica"
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.li>
);
}
/**
* Campanella accanto al profilo (M17): badge con le notifiche non lette, pannello con
* l'elenco al click. Copre quattro sorgenti generate lato database (mai dal client):
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
* `src/lib/notifiche-utente.ts`.
*
* Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
* chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
* toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
*/
export function IconaNotifiche() {
const { notifiche, nonLette } = useNotificheMie();
const segnaLette = useSegnaLette();
const eliminaNotifica = useEliminaNotifica();
const [aperto, setAperto] = useState(false);
const riquadro = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!aperto) return;
const chiudiSeFuori = (e: MouseEvent) => {
if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
};
const chiudiConEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setAperto(false);
};
document.addEventListener("mousedown", chiudiSeFuori);
document.addEventListener("keydown", chiudiConEsc);
return () => {
document.removeEventListener("mousedown", chiudiSeFuori);
document.removeEventListener("keydown", chiudiConEsc);
};
}, [aperto]);
function alClick() {
const stavaChiuso = !aperto;
setAperto(stavaChiuso);
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
}
return (
<div ref={riquadro} className="relative shrink-0">
<button
type="button"
onClick={alClick}
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
className="premi relative flex h-12 w-12 items-center justify-center rounded-2xl text-primary-foreground ring-2 ring-primary-foreground/30"
>
<Bell className="h-5 w-5" />
{nonLette > 0 ? (
<span className="bg-accent-grad absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold text-accent-foreground shadow-pop">
{contatoreBadge(nonLette)}
</span>
) : null}
</button>
{aperto ? (
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
{notifiche.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">Nessuna notifica</p>
) : (
<ul className="max-h-80 overflow-y-auto">
<AnimatePresence initial={false}>
{notifiche.map((n) => (
<RigaNotifica key={n.id} notifica={n} onElimina={eliminaNotifica.mutate} />
))}
</AnimatePresence>
</ul>
)}
</div>
) : null}
</div>
);
}
export function PageHeader({
titolo,
sottotitolo,
@@ -91,7 +241,12 @@ export function PageHeader({
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
) : null}
</div>
{azione ?? <LinkProfilo />}
{azione ?? (
<div className="flex shrink-0 items-center gap-2">
<IconaNotifiche />
<LinkProfilo />
</div>
)}
</div>
</header>
);