import { useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type ReactNode, } from "react"; import { Link } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/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"; import { useGiocatoreBase } from "@/lib/user-store"; import { useMotoRidotto } from "@/lib/motion"; import { molla, proietta } from "@/lib/molla"; import { pallinoNotifiche, 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"; export function TeamLogo({ className, /** * Di default l'icona della PWA, che ha dentro il nome dell'app. Dove serve lo * stemma della squadra e basta si passa `/logo-nerorosso.svg`. */ src = "/icon-192.png", }: { className?: string; src?: string; }) { return ( CRAP Volley ); } /** * Superficie standard dell'app: era ripetuta a mano una ventina di volte come * `rounded-3xl bg-card p-4 shadow-card`, quindi cambiare raggio od ombra * voleva dire toccare venti file. * * Gerarchia dei raggi: contenitore `3xl` → elemento interno `2xl` → * controllo `full`. */ export function Card({ className, as: Tag = "div", ...props }: ComponentPropsWithoutRef<"div"> & { as?: "div" | "article" | "section" }) { return ; } /** "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 ( { 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" >

{notifica.titolo}

{notifica.corpo ?

{notifica.corpo}

: null}

{tempoRelativo(notifica.creataIl)}

); } /** * 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 ; return (
); } /** * 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`. * * 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`. */ function PallinoNotifiche() { const { notifiche, nonLette } = useNotificheMie(); const segnaLette = useSegnaLette(); const eliminaNotifica = useEliminaNotifica(); const [aperto, setAperto] = useState(false); const riquadro = useRef(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]); 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 (
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */} {aperto ? (
    {notifiche.map((n) => ( ))}
) : null}
); } export function PageHeader({ titolo, sottotitolo, azione, }: { titolo: string; sottotitolo?: string; /** Sostituisce il link al profilo in alto a destra. */ azione?: ReactNode; }) { return (

{titolo}

{sottotitolo ? (

{sottotitolo}

) : null}
{azione ?? }
); } export function Section({ titolo, azione, children, indice = 0, }: { titolo?: string; azione?: ReactNode; children: ReactNode; indice?: number; }) { return ( {titolo || azione ? (
{titolo ?

{titolo}

: } {azione}
) : null} {children}
); } /** Sezione con titolo cliccabile: il contenuto si apre e chiude. `anteprima` resta sempre visibile. */ export function SezioneTendina({ titolo, children, anteprima, azione, defaultAperta = false, indice = 0, }: { titolo: string; children: ReactNode; anteprima?: ReactNode; azione?: ReactNode; defaultAperta?: boolean; indice?: number; }) { const [aperta, setAperta] = useState(defaultAperta); const id = useId(); return ( {anteprima}
{aperta ? children : null}
); } /** * Classi condivise di input, select e textarea nei form dell'app. `h-10`: * `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche * con `appearance-none`, restano un filo più alti di un input di testo a * parità di padding — un'altezza esplicita allinea tutti i campi tra loro. */ export const classiInput = "h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm"; /** * Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label * è l'elemento di griglia e ha `min-width: auto`, quindi si allarga fino al * contenuto invece di stare nella colonna. Con un controllo nativo largo dentro * (una data o un'ora su iOS) la coppia sfonda la card. */ export function Campo({ label, children }: { label: string; children: ReactNode }) { return ( ); } /** * `` ignora parzialmente il padding di * `classiInput` (soprattutto su Android), risultando più alto o più basso * degli input accanto: `appearance-none` lo riporta a una scatola CSS * normale, la freccia va poi ridisegnata a mano perché sparisce con lui. */ export function Select(props: ComponentPropsWithoutRef<"select">) { return (