2026-09-15 11:19:07 +02:00
|
|
|
|
import {
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useId,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type ComponentPropsWithoutRef,
|
|
|
|
|
|
type ReactNode,
|
|
|
|
|
|
} from "react";
|
2026-09-05 19:22:08 +02:00
|
|
|
|
import { Link } from "@tanstack/react-router";
|
2026-09-15 11:19:07 +02:00
|
|
|
|
import { AnimatePresence, motion } from "motion/react";
|
2026-09-24 13:53:58 +02:00
|
|
|
|
import { ChevronDown, X } from "lucide-react";
|
2026-08-06 08:36:47 +02:00
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-09-08 09:47:31 +02:00
|
|
|
|
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
|
|
|
|
|
|
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
|
|
|
|
|
import { useGiocatoreBase } from "@/lib/user-store";
|
2026-09-15 11:19:07 +02:00
|
|
|
|
import { useMotoRidotto } from "@/lib/motion";
|
|
|
|
|
|
import { molla, proietta } from "@/lib/molla";
|
|
|
|
|
|
import {
|
2026-09-24 13:53:58 +02:00
|
|
|
|
pallinoNotifiche,
|
2026-09-15 11:19:07 +02:00
|
|
|
|
useEliminaNotifica,
|
|
|
|
|
|
useNotificheMie,
|
|
|
|
|
|
useSegnaLette,
|
|
|
|
|
|
type NotificaUtente,
|
|
|
|
|
|
} from "@/lib/notifiche-utente";
|
2026-09-05 19:22:08 +02:00
|
|
|
|
import { Avatar } from "@/components/crapp/Avatar";
|
2026-08-06 08:36:47 +02:00
|
|
|
|
import { Reveal } from "@/components/motion/Reveal";
|
|
|
|
|
|
import { Numero } from "@/components/motion/Numero";
|
|
|
|
|
|
|
2026-09-05 19:35:44 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}) {
|
2026-08-06 08:36:47 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<img
|
2026-09-05 19:35:44 +02:00
|
|
|
|
src={src}
|
2026-08-06 08:36:47 +02:00
|
|
|
|
alt="CRAP Volley"
|
2026-09-05 12:42:24 +02:00
|
|
|
|
width={192}
|
|
|
|
|
|
height={192}
|
2026-09-08 09:39:40 +02:00
|
|
|
|
decoding="async"
|
2026-08-06 08:36:47 +02:00
|
|
|
|
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 12:42:24 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 11:19:07 +02:00
|
|
|
|
/** "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);
|
|
|
|
|
|
}}
|
2026-10-06 11:50:28 +02:00
|
|
|
|
className="touch-pan-y bg-card p-3 pr-8 text-[15px]"
|
2026-09-15 11:19:07 +02:00
|
|
|
|
>
|
|
|
|
|
|
<p className="font-semibold">{notifica.titolo}</p>
|
2026-09-30 14:03:50 +02:00
|
|
|
|
{notifica.corpo ? (
|
|
|
|
|
|
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
|
|
|
|
|
) : null}
|
2026-10-06 11:50:28 +02:00
|
|
|
|
<p className="mt-1 text-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
2026-09-15 11:19:07 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-24 13:53:58 +02:00
|
|
|
|
* 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):
|
2026-09-15 11:19:07 +02:00
|
|
|
|
* 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`.
|
|
|
|
|
|
*/
|
2026-09-24 13:53:58 +02:00
|
|
|
|
function PallinoNotifiche() {
|
2026-09-15 11:19:07 +02:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-09-24 13:53:58 +02:00
|
|
|
|
const pallino = pallinoNotifiche(notifiche.length, nonLette);
|
|
|
|
|
|
if (!pallino) return null;
|
|
|
|
|
|
|
2026-09-15 11:19:07 +02:00
|
|
|
|
function alClick() {
|
|
|
|
|
|
const stavaChiuso = !aperto;
|
|
|
|
|
|
setAperto(stavaChiuso);
|
|
|
|
|
|
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 13:53:58 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 11:19:07 +02:00
|
|
|
|
return (
|
2026-09-24 13:53:58 +02:00
|
|
|
|
<div ref={riquadro}>
|
|
|
|
|
|
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
|
2026-09-15 11:19:07 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={alClick}
|
|
|
|
|
|
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
|
2026-09-24 13:53:58 +02:00
|
|
|
|
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",
|
|
|
|
|
|
)}
|
2026-09-15 11:19:07 +02:00
|
|
|
|
>
|
2026-09-24 13:53:58 +02:00
|
|
|
|
{pallino.testo}
|
2026-09-15 11:19:07 +02:00
|
|
|
|
</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">
|
2026-09-24 13:53:58 +02:00
|
|
|
|
<ul className="max-h-80 overflow-y-auto">
|
|
|
|
|
|
<AnimatePresence initial={false}>
|
|
|
|
|
|
{notifiche.map((n) => (
|
|
|
|
|
|
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
</ul>
|
2026-09-15 11:19:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:22:08 +02:00
|
|
|
|
export function PageHeader({
|
|
|
|
|
|
titolo,
|
|
|
|
|
|
sottotitolo,
|
|
|
|
|
|
azione,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
titolo: string;
|
|
|
|
|
|
sottotitolo?: string;
|
|
|
|
|
|
/** Sostituisce il link al profilo in alto a destra. */
|
|
|
|
|
|
azione?: ReactNode;
|
|
|
|
|
|
}) {
|
2026-08-06 08:36:47 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<header className="bg-hero px-5 pb-8 pt-7 text-primary-foreground">
|
|
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
|
|
|
|
|
|
<div className="min-w-0">
|
2026-09-05 12:42:24 +02:00
|
|
|
|
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
|
2026-08-06 08:36:47 +02:00
|
|
|
|
{sottotitolo ? (
|
2026-10-06 11:50:28 +02:00
|
|
|
|
<p className="mt-1 truncate text-[15px] text-primary-foreground/80">{sottotitolo}</p>
|
2026-08-06 08:36:47 +02:00
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
2026-09-24 13:53:58 +02:00
|
|
|
|
{azione ?? <LinkProfilo />}
|
2026-08-06 08:36:47 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function Section({
|
|
|
|
|
|
titolo,
|
|
|
|
|
|
azione,
|
|
|
|
|
|
children,
|
|
|
|
|
|
indice = 0,
|
|
|
|
|
|
}: {
|
2026-09-07 11:38:03 +02:00
|
|
|
|
titolo?: string;
|
2026-08-06 08:36:47 +02:00
|
|
|
|
azione?: ReactNode;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
indice?: number;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Reveal as="section" indice={indice} className="px-5 py-4">
|
2026-09-07 11:38:03 +02:00
|
|
|
|
{titolo || azione ? (
|
|
|
|
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
2026-09-07 11:42:08 +02:00
|
|
|
|
{titolo ? <h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2> : <span />}
|
2026-09-07 11:38:03 +02:00
|
|
|
|
{azione}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-08-06 08:36:47 +02:00
|
|
|
|
{children}
|
|
|
|
|
|
</Reveal>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 12:01:13 +02:00
|
|
|
|
/** Sezione con titolo cliccabile: il contenuto si apre e chiude. `anteprima` resta sempre visibile. */
|
|
|
|
|
|
export function SezioneTendina({
|
|
|
|
|
|
titolo,
|
|
|
|
|
|
children,
|
|
|
|
|
|
anteprima,
|
2026-09-04 12:43:53 +02:00
|
|
|
|
azione,
|
2026-09-04 12:01:13 +02:00
|
|
|
|
defaultAperta = false,
|
|
|
|
|
|
indice = 0,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
titolo: string;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
anteprima?: ReactNode;
|
2026-09-04 12:43:53 +02:00
|
|
|
|
azione?: ReactNode;
|
2026-09-04 12:01:13 +02:00
|
|
|
|
defaultAperta?: boolean;
|
|
|
|
|
|
indice?: number;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [aperta, setAperta] = useState(defaultAperta);
|
2026-09-05 12:42:24 +02:00
|
|
|
|
const id = useId();
|
2026-09-04 12:01:13 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<Reveal as="section" indice={indice} className="px-5 py-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setAperta((v) => !v)}
|
2026-11-06 23:47:36 +01:00
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]",
|
|
|
|
|
|
(aperta || anteprima) && "mb-3",
|
|
|
|
|
|
)}
|
2026-09-04 12:01:13 +02:00
|
|
|
|
aria-expanded={aperta}
|
2026-09-05 12:42:24 +02:00
|
|
|
|
aria-controls={id}
|
2026-09-04 12:01:13 +02:00
|
|
|
|
>
|
2026-11-06 23:47:36 +01:00
|
|
|
|
<h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2>
|
2026-09-04 12:43:53 +02:00
|
|
|
|
<span className="flex shrink-0 items-center gap-2">
|
|
|
|
|
|
{azione}
|
|
|
|
|
|
<ChevronDown
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-4 w-4 text-muted-foreground transition-transform",
|
|
|
|
|
|
aperta && "rotate-180",
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
2026-09-04 12:01:13 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
{anteprima}
|
2026-09-05 12:42:24 +02:00
|
|
|
|
<div id={id}>{aperta ? children : null}</div>
|
2026-09-04 12:01:13 +02:00
|
|
|
|
</Reveal>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 11:07:15 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
2026-09-05 23:41:20 +02:00
|
|
|
|
export const classiInput =
|
2026-10-06 11:50:28 +02:00
|
|
|
|
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
|
2026-09-05 23:41:20 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 (
|
|
|
|
|
|
<label className="block min-w-0">
|
2026-10-06 11:50:28 +02:00
|
|
|
|
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
2026-09-05 23:41:20 +02:00
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="mt-1 block">{children}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 10:57:05 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* `<select>` nativo con la stessa altezza degli altri campi del form. Il
|
|
|
|
|
|
* controllo nativo di un `<select>` 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 (
|
|
|
|
|
|
<span className="relative block">
|
|
|
|
|
|
<select {...props} className={cn(classiInput, "appearance-none pr-8", props.className)} />
|
|
|
|
|
|
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 08:36:47 +02:00
|
|
|
|
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
|
|
|
|
|
|
const meta = statoMeta[stato];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
2026-10-06 11:50:28 +02:00
|
|
|
|
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
2026-08-06 08:36:47 +02:00
|
|
|
|
meta.className,
|
|
|
|
|
|
className,
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-09-01 13:38:33 +02:00
|
|
|
|
{meta.label}
|
2026-08-06 08:36:47 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function StatTile({
|
|
|
|
|
|
valore,
|
|
|
|
|
|
label,
|
|
|
|
|
|
hint,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
valore: ReactNode;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
hint?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="premi rounded-2xl bg-card p-3 shadow-card">
|
|
|
|
|
|
<p className="font-display text-2xl leading-none">
|
|
|
|
|
|
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
|
|
|
|
|
|
</p>
|
2026-10-06 11:50:28 +02:00
|
|
|
|
<p className="mt-1 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
|
2026-08-06 08:36:47 +02:00
|
|
|
|
{label}
|
|
|
|
|
|
</p>
|
2026-10-06 11:50:28 +02:00
|
|
|
|
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
|
2026-08-06 08:36:47 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-09-01 13:38:33 +02:00
|
|
|
|
}
|