Avvisi in Home: componente condiviso, più leggibili; turno palloni ridisegnato

Certificati e turno palloni usano lo stesso componente Avviso: icona propria per
ogni caso (il colore non è l'unico segnale), testo più grande, elenchi di persone
in righe con dettaglio a destra e conteggio, freccia sulle card cliccabili,
comparsa graduale. Il turno palloni ha la pillola «Oggi»/«In arrivo» e i
messaggi in riquadri. Solo aspetto: testi e regole di comparsa invariati.
Test del componente, CHANGELOG, ARCHITECTURE e test/README aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-06 11:44:58 +02:00
1 parent e7ae2c1884
commit 519f9aaff4
7 files changed
+258 -74

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Reveal } from "@/components/motion/Reveal";
import { cn } from "@/lib/utils";
/**
* Gravità di un avviso in Home. Il colore non è l'unico segnale: ogni avviso ha anche un'icona
* sua e un titolo, così si distingue anche senza vedere i colori.
*/
export type TonoAvviso = "critico" | "attenzione" | "scaduto" | "azione";
const toni: Record<TonoAvviso, string> = {
critico: "bg-destructive text-destructive-foreground",
attenzione: "bg-warning text-warning-foreground",
scaduto: "bg-primary text-primary-foreground",
azione: "bg-accent-grad text-accent-foreground",
};
/** Una riga di un elenco: a sinistra chi, a destra il dettaglio (es. la scadenza). */
export type RigaAvviso = { chi: string; cosa: string };
type ProprietaCorpo = {
tono: TonoAvviso;
icona: LucideIcon;
titolo: string;
/** Frasi di spiegazione, una per paragrafo. */
testi?: string[];
/** Elenco di persone con il loro dettaglio; con più di una riga compare il conteggio. */
elenco?: RigaAvviso[];
/** Mostra la freccia: tutta la card porta a un'altra schermata. */
cliccabile?: boolean;
/** Parola breve accanto al titolo (es. «Oggi»), per dire subito quando riguarda. */
etichetta?: string;
/** I testi in riquadri propri, uno per messaggio: per chi deve fare una cosa precisa. */
riquadri?: boolean;
};
/** Contenuto dell'avviso, senza margini né link: è la parte che si può verificare nei test. */
export function CorpoAvviso({
tono,
icona: Icona,
titolo,
testi = [],
elenco = [],
cliccabile = false,
etichetta,
riquadri = false,
}: ProprietaCorpo) {
return (
<div className={cn("rounded-3xl p-5 shadow-pop", toni[tono])}>
<div className="flex items-center gap-3">
<span
aria-hidden
className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-current/15"
>
<Icona className="h-6 w-6" />
</span>
<p className="min-w-0 flex-1 font-display text-lg uppercase leading-tight tracking-wide">
{titolo}
</p>
{etichetta ? (
<span className="rounded-full bg-black/25 px-3 py-1 text-[13px] font-bold uppercase tracking-wide">
{etichetta}
</span>
) : null}
{elenco.length > 1 ? (
<span
aria-label={`${elenco.length} persone`}
className="rounded-full bg-current/15 px-3 py-1 text-[13px] font-bold tabular-nums"
>
{elenco.length}
</span>
) : null}
{cliccabile ? <ChevronRight aria-hidden className="h-6 w-6 shrink-0" /> : null}
</div>
{testi.map((t) => (
<p
key={t}
className={cn(
"mt-3 text-[15px] leading-snug",
riquadri && "rounded-2xl bg-black/15 p-3.5 font-semibold",
)}
>
{t}
</p>
))}
{elenco.length > 0 ? (
<ul className="mt-3 divide-y divide-current/15 border-t border-current/15">
{elenco.map((r) => (
<li
key={`${r.chi}-${r.cosa}`}
className="flex items-baseline justify-between gap-3 py-2.5 text-[15px] leading-snug"
>
<span className="min-w-0 truncate font-semibold">{r.chi}</span>
<span className="shrink-0 text-right tabular-nums">{r.cosa}</span>
</li>
))}
</ul>
) : null}
</div>
);
}
/**
* Avviso in Home: comparsa graduale, margini della colonna e, se serve, link ai documenti del
* profilo. Tutti gli avvisi (certificati, turno palloni) passano da qui per avere lo stesso aspetto.
*/
export function Avviso({ indice = 0, ...corpo }: ProprietaCorpo & { indice?: number }) {
const card = <CorpoAvviso {...corpo} />;
return (
<Reveal indice={indice} className="mx-5 mt-4">
{corpo.cliccabile ? (
<Link
to="/profilo"
search={{ tab: "documenti" }}
className="premi block rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{card}
</Link>
) : (
<div role="status">{card}</div>
)}
</Reveal>
);
}
+28 -61
View File
@@ -1,5 +1,5 @@
import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { CalendarClock, FileClock, FileX } from "lucide-react";
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
import { useProfili } from "@/lib/profili";
import {
avvisiCertificatiUtente,
@@ -12,45 +12,6 @@ import { oggiISO } from "@/lib/palloni-core";
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
function Card({
colore,
titolo,
righe,
cliccabile,
}: {
colore: "warning" | "primary" | "destructive";
titolo: string;
righe: string[];
cliccabile?: boolean;
}) {
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
colore === "warning"
? "bg-warning text-warning-foreground"
: colore === "destructive"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
} ${cliccabile ? "premi" : ""}`;
const contenuto = (
<>
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> {titolo}
</p>
{righe.map((r) => (
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
{r}
</p>
))}
</>
);
return cliccabile ? (
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
{contenuto}
</Link>
) : (
<div className={classi}>{contenuto}</div>
);
}
/**
* Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
@@ -72,28 +33,31 @@ export function AvvisoCertificati() {
base,
});
const cardPersonale = personaleMancante ? (
<Card
colore="destructive"
<Avviso
tono="critico"
icona={FileX}
titolo="Certificato mancante"
cliccabile
righe={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
/>
) : personale ? (
personale.giorni < 0 ? (
<Card
colore="primary"
<Avviso
tono="scaduto"
icona={FileClock}
titolo="Certificato scaduto"
cliccabile
righe={[
testi={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Card
colore="warning"
<Avviso
tono="attenzione"
icona={CalendarClock}
titolo="Certificato in scadenza"
cliccabile
righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
/>
)
) : null;
@@ -104,30 +68,33 @@ export function AvvisoCertificati() {
<>
{cardPersonale}
{mancanti.length > 0 ? (
<Card
colore="destructive"
<Avviso
tono="critico"
icona={FileX}
titolo="Certificati mancanti"
righe={mancanti.map((m) => `${m.nome} ${m.cognome} — non caricato`)}
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
/>
) : null}
{scaduti.length > 0 ? (
<Card
colore="primary"
<Avviso
tono="scaduto"
icona={FileClock}
titolo="Certificati scaduti"
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Card
colore="warning"
<Avviso
tono="attenzione"
icona={CalendarClock}
titolo="Certificati in scadenza"
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
return `${a.nome} ${a.cognome} — ${dettaglio}`;
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
}
+13 -11
View File
@@ -1,4 +1,5 @@
import { AlertCircle } from "lucide-react";
import { Volleyball } from "lucide-react";
import { Avviso } from "@/components/crapp/Avviso";
import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
@@ -17,6 +18,8 @@ export function PromemoriaPalloni() {
const oggi = oggiISO();
const messaggi: string[] = [];
// «Oggi» solo se c'è davvero qualcosa da fare oggi; altrimenti è un semplice promemoria.
let oggiTocca = true;
for (const evento of lista) {
if (evento.data !== oggi) continue;
@@ -36,6 +39,7 @@ export function PromemoriaPalloni() {
}
if (messaggi.length === 0) {
oggiTocca = false;
const prossimo = lista.find((e) => e.data >= oggi && turni[e.id] === io.id);
if (!prossimo) return null;
messaggi.push(
@@ -44,15 +48,13 @@ export function PromemoriaPalloni() {
}
return (
<div className="mx-5 mt-4 rounded-3xl bg-accent-grad p-4 text-accent-foreground shadow-pop">
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> Turno palloni
</p>
{messaggi.map((m) => (
<p key={m} className="mt-2 text-xs leading-snug opacity-90">
{m}
</p>
))}
</div>
<Avviso
tono="azione"
icona={Volleyball}
titolo="Turno palloni"
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
testi={messaggi}
riquadri
/>
);
}