Sposta il contatore delle notifiche in un pallino sull'avatar del profilo
Tolta la campanella in alto a destra: il numero delle non lette è ora un pallino rosso sull'angolo dell'avatar. Il tap sull'avatar porta sempre al profilo, quello sul pallino apre l'elenco delle notifiche (lettura ed eliminazione). Con tutte le notifiche lette il pallino resta neutro col totale, così restano eliminabili. Logica del pallino in pallinoNotifiche(), coperta da test; documentazione allineata. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c6437a605c
commit
33ed2398ab
10 files changed
+119
-82
No files matched your search
@@ -8,7 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Bell, ChevronDown, X } from "lucide-react";
|
||||
import { 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";
|
||||
@@ -16,7 +16,7 @@ import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
import { molla, proietta } from "@/lib/molla";
|
||||
import {
|
||||
contatoreBadge,
|
||||
pallinoNotifiche,
|
||||
useEliminaNotifica,
|
||||
useNotificheMie,
|
||||
useSegnaLette,
|
||||
@@ -65,30 +65,6 @@ export function Card({
|
||||
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
|
||||
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
|
||||
* `PageHeader` con il logo che porta alla home.
|
||||
*
|
||||
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
|
||||
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
|
||||
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
|
||||
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
|
||||
*/
|
||||
export function LinkProfilo() {
|
||||
const g = useGiocatoreBase();
|
||||
if (!g) return <TeamLogo className="h-12 w-12" />;
|
||||
return (
|
||||
<Link
|
||||
to="/profilo"
|
||||
aria-label="Il tuo profilo"
|
||||
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
|
||||
>
|
||||
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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" });
|
||||
@@ -150,8 +126,38 @@ function RigaNotifica({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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):
|
||||
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
|
||||
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
|
||||
* `PageHeader` con il logo che porta alla home.
|
||||
*
|
||||
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
|
||||
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
|
||||
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
|
||||
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
|
||||
*
|
||||
* Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar
|
||||
* porta sempre al profilo, quello sul pallino apre il pannello.
|
||||
*/
|
||||
export function LinkProfilo() {
|
||||
const g = useGiocatoreBase();
|
||||
if (!g) return <TeamLogo className="h-12 w-12" />;
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<Link
|
||||
to="/profilo"
|
||||
aria-label="Il tuo profilo"
|
||||
className="premi block rounded-2xl ring-2 ring-primary-foreground/30"
|
||||
>
|
||||
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
|
||||
</Link>
|
||||
<PallinoNotifiche />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da
|
||||
* `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client):
|
||||
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
|
||||
* `src/lib/notifiche-utente.ts`.
|
||||
*
|
||||
@@ -159,7 +165,7 @@ function RigaNotifica({
|
||||
* 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() {
|
||||
function PallinoNotifiche() {
|
||||
const { notifiche, nonLette } = useNotificheMie();
|
||||
const segnaLette = useSegnaLette();
|
||||
const eliminaNotifica = useEliminaNotifica();
|
||||
@@ -182,40 +188,48 @@ export function IconaNotifiche() {
|
||||
};
|
||||
}, [aperto]);
|
||||
|
||||
const pallino = pallinoNotifiche(notifiche.length, nonLette);
|
||||
if (!pallino) return null;
|
||||
|
||||
function alClick() {
|
||||
const stavaChiuso = !aperto;
|
||||
setAperto(stavaChiuso);
|
||||
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
|
||||
}
|
||||
|
||||
// Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima
|
||||
// notifica non si riapre da solo.
|
||||
function elimina(id: string) {
|
||||
if (notifiche.length === 1) setAperto(false);
|
||||
eliminaNotifica.mutate(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={riquadro} className="relative shrink-0">
|
||||
<div ref={riquadro}>
|
||||
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
|
||||
<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"
|
||||
aria-expanded={aperto}
|
||||
className={cn(
|
||||
"absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold shadow-pop ring-2 ring-primary-foreground after:absolute after:-inset-3 active:scale-90",
|
||||
pallino.daLeggere
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
{pallino.testo}
|
||||
</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>
|
||||
)}
|
||||
<ul className="max-h-80 overflow-y-auto">
|
||||
<AnimatePresence initial={false}>
|
||||
{notifiche.map((n) => (
|
||||
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -241,12 +255,7 @@ export function PageHeader({
|
||||
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{azione ?? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<IconaNotifiche />
|
||||
<LinkProfilo />
|
||||
</div>
|
||||
)}
|
||||
{azione ?? <LinkProfilo />}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabel
|
||||
import { useGiocatoreId } from "./user-store";
|
||||
|
||||
/**
|
||||
* Centro notifiche in-app (M17): icona a campana accanto al profilo, con badge delle non
|
||||
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
|
||||
* lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
|
||||
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
|
||||
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
|
||||
@@ -129,3 +129,17 @@ export function useEliminaNotifica() {
|
||||
export function contatoreBadge(nonLette: number): string {
|
||||
return nonLette > 9 ? "9+" : String(nonLette);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette
|
||||
* resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più
|
||||
* raggiungibili per eliminarle. Senza notifiche non c'è (`null`).
|
||||
*/
|
||||
export function pallinoNotifiche(
|
||||
totale: number,
|
||||
nonLette: number,
|
||||
): { testo: string; daLeggere: boolean } | null {
|
||||
if (totale === 0) return null;
|
||||
if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true };
|
||||
return { testo: contatoreBadge(totale), daLeggere: false };
|
||||
}
|
||||
+2
-12
@@ -2,14 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Flame, ChevronRight } from "lucide-react";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||
import {
|
||||
Card,
|
||||
IconaNotifiche,
|
||||
LinkProfilo,
|
||||
Section,
|
||||
StatTile,
|
||||
TeamLogo,
|
||||
} from "@/components/crapp/ui-bits";
|
||||
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
@@ -90,10 +83,7 @@ function Index() {
|
||||
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<IconaNotifiche />
|
||||
<LinkProfilo />
|
||||
</div>
|
||||
<LinkProfilo />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
|
||||
|
||||
Reference in new issue
Block a user