Riduce il lag su Android: tab memoizzate, vetro/blur/drag disattivati sui device deboli
- squadra.tsx e classifica.tsx: il contenuto di ogni tab di BarraSottosezioni è ora memoizzato con useMemo, cosi' uno stato locale o un refetch in background non ricalcolano piu' anche le sezioni nascoste. - BottomNav, BarraSottosezioni, calendario: useMotoRidotto (RAM bassa o prefers-reduced-motion) disattiva sui device deboli la catena di filtri SVG piu' pesante di .vetro, il backdrop-blur della barra tab e il drag orizzontale; su iOS e device performanti resta tutto invariato. - styles.css: rimossa l'utility .materiale, orfana da quando BottomNav e' passata a .vetro. - ui-bits.tsx: decoding="async" su TeamLogo. - todo.md: traccia le cause individuate e lo stato di applicazione di ciascuna. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
|
||||
export type VoceSottosezione = {
|
||||
id: string;
|
||||
@@ -38,7 +39,11 @@ export function BarraSottosezioni({
|
||||
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
|
||||
const direzione = useRef(0);
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const ridotto = useReducedMotion();
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
|
||||
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
|
||||
// sottosezioni (Squadra, Campionato, Profilo).
|
||||
const ridotto = useMotoRidotto();
|
||||
const indice = Math.max(
|
||||
0,
|
||||
voci.findIndex((v) => v.id === attiva),
|
||||
@@ -69,7 +74,8 @@ export function BarraSottosezioni({
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full border-b border-border bg-background",
|
||||
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
|
||||
!sottolineatura &&
|
||||
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
|
||||
)}
|
||||
>
|
||||
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
|
||||
@@ -136,7 +142,9 @@ export function BarraSottosezioni({
|
||||
key={voce.id}
|
||||
role="tabpanel"
|
||||
aria-label={voce.label}
|
||||
drag="x"
|
||||
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
|
||||
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
|
||||
drag={ridotto ? false : "x"}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.12}
|
||||
dragMomentum={false}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import { molla } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
|
||||
// Quattro voci e non cinque: il profilo sta in alto a destra
|
||||
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
|
||||
@@ -13,7 +14,10 @@ const items = [
|
||||
] as const;
|
||||
|
||||
export function BottomNav() {
|
||||
const ridotto = useReducedMotion();
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
|
||||
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
|
||||
const ridotto = useMotoRidotto();
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -62,7 +66,13 @@ export function BottomNav() {
|
||||
*/}
|
||||
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
|
||||
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
|
||||
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
|
||||
<div
|
||||
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
|
||||
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
|
||||
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
|
||||
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
|
||||
{...(ridotto ? { "data-leggero": "" } : {})}
|
||||
>
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={to}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function TeamLogo({
|
||||
alt="CRAP Volley"
|
||||
width={192}
|
||||
height={192}
|
||||
decoding="async"
|
||||
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useEffect, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { molla, proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
@@ -115,7 +116,9 @@ function Calendario() {
|
||||
const admin = useIsAdmin();
|
||||
const { eventi } = useEventi();
|
||||
const rosa = useRosa();
|
||||
const ridotto = useReducedMotion();
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
||||
const ridotto = useMotoRidotto();
|
||||
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
|
||||
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||
@@ -197,7 +200,7 @@ function Calendario() {
|
||||
<motion.div
|
||||
key={mesePrefix}
|
||||
custom={direzione}
|
||||
drag="x"
|
||||
drag={ridotto ? false : "x"}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.18}
|
||||
dragMomentum={false}
|
||||
|
||||
+159
-152
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -48,32 +49,166 @@ function Classifica() {
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { data: csi } = useCsi();
|
||||
const votiMvp = useVotiMvp();
|
||||
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
|
||||
const { eventi } = useEventi();
|
||||
|
||||
const classifica = csi?.classifica ?? [];
|
||||
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
||||
const eventoIdPerData = new Map(
|
||||
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]),
|
||||
const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
|
||||
|
||||
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
|
||||
const eventoIdPerData = useMemo(
|
||||
() => new Map(eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id])),
|
||||
[eventi],
|
||||
);
|
||||
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
|
||||
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
|
||||
const tuttiMatch = csiGiocate.length
|
||||
? csiGiocate.map((p) => {
|
||||
const m = matchDaPartitaCsi(p);
|
||||
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
||||
})
|
||||
: scoutMatches.map((m) => ({
|
||||
id: m.id,
|
||||
data: m.data,
|
||||
avversario: m.avversario,
|
||||
casa: m.casa,
|
||||
setNostri: m.setNostri,
|
||||
setLoro: m.setLoro,
|
||||
parziali: m.parziali,
|
||||
mvp: mvpPerData(m.data),
|
||||
scout: true,
|
||||
}));
|
||||
const tuttiMatch = useMemo(() => {
|
||||
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
|
||||
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
||||
return csiGiocate.length
|
||||
? csiGiocate.map((p) => {
|
||||
const m = matchDaPartitaCsi(p);
|
||||
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
||||
})
|
||||
: scoutMatches.map((m) => ({
|
||||
id: m.id,
|
||||
data: m.data,
|
||||
avversario: m.avversario,
|
||||
casa: m.casa,
|
||||
setNostri: m.setNostri,
|
||||
setLoro: m.setLoro,
|
||||
parziali: m.parziali,
|
||||
mvp: mvpPerData(m.data),
|
||||
scout: true,
|
||||
}));
|
||||
}, [csi, scoutMatches, mvpPerMatch, eventoIdPerData]);
|
||||
|
||||
// Ogni tab è memoizzata: un refetch in background di uno solo dei dati (CSI,
|
||||
// scout, eventi, voti MVP) non deve ricostruire il JSX dell'altra tab.
|
||||
const contenutoClassifica = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
|
||||
<RefreshCw className="h-3.5 w-3.5 text-accent" />
|
||||
{csi
|
||||
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
|
||||
: "Dati CSI in arrivo"}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<span>#</span>
|
||||
<span>Squadra</span>
|
||||
<span className="text-center">G</span>
|
||||
<span className="text-center">Set</span>
|
||||
<span className="text-center">Pt</span>
|
||||
</div>
|
||||
{classifica.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
Classifica non ancora disponibile.
|
||||
</p>
|
||||
) : (
|
||||
classifica.map((r) => {
|
||||
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
||||
return (
|
||||
<div
|
||||
key={r.pos}
|
||||
className={cn(
|
||||
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
||||
noi && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
<span className={cn("font-display text-base", noi && "text-accent")}>
|
||||
{r.pos}
|
||||
</span>
|
||||
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
|
||||
{r.squadra}
|
||||
</span>
|
||||
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
|
||||
<span className="text-center text-xs tabular-nums text-muted-foreground">
|
||||
{r.setFatti}:{r.setSubiti}
|
||||
</span>
|
||||
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
[csi, classifica],
|
||||
);
|
||||
|
||||
const contenutoStorico = useMemo(
|
||||
() =>
|
||||
tuttiMatch.length === 0 ? (
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
||||
Nessuna partita disponibile.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tuttiMatch.map((m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
const eventoId = eventoIdPerData.get(m.data);
|
||||
const contenuto = (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
||||
{m.casa ? m.avversario : "CRAP Volley"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-xl px-2.5 py-1 font-display text-lg",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{m.setNostri}-{m.setLoro}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{m.parziali.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
{eventoId && !m.mvp ? (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return eventoId ? (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita/$id"
|
||||
params={{ id: eventoId }}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||
{contenuto}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
[tuttiMatch, eventoIdPerData],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,136 +222,8 @@ function Classifica() {
|
||||
variante="sottolineatura"
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{
|
||||
id: "classifica",
|
||||
label: "Classifica",
|
||||
contenuto: (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
|
||||
<RefreshCw className="h-3.5 w-3.5 text-accent" />
|
||||
{csi
|
||||
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
|
||||
: "Dati CSI in arrivo"}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<span>#</span>
|
||||
<span>Squadra</span>
|
||||
<span className="text-center">G</span>
|
||||
<span className="text-center">Set</span>
|
||||
<span className="text-center">Pt</span>
|
||||
</div>
|
||||
{classifica.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
Classifica non ancora disponibile.
|
||||
</p>
|
||||
) : (
|
||||
classifica.map((r) => {
|
||||
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
||||
return (
|
||||
<div
|
||||
key={r.pos}
|
||||
className={cn(
|
||||
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
||||
noi && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
<span className={cn("font-display text-base", noi && "text-accent")}>
|
||||
{r.pos}
|
||||
</span>
|
||||
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
|
||||
{r.squadra}
|
||||
</span>
|
||||
<span className="text-center text-xs text-muted-foreground">
|
||||
{r.giocate}
|
||||
</span>
|
||||
<span className="text-center text-xs tabular-nums text-muted-foreground">
|
||||
{r.setFatti}:{r.setSubiti}
|
||||
</span>
|
||||
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "storico",
|
||||
label: "Storico partite",
|
||||
contenuto:
|
||||
tuttiMatch.length === 0 ? (
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
||||
Nessuna partita disponibile.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tuttiMatch.map((m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
const eventoId = eventoIdPerData.get(m.data);
|
||||
const contenuto = (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
||||
{m.casa ? m.avversario : "CRAP Volley"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-xl px-2.5 py-1 font-display text-lg",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{m.setNostri}-{m.setLoro}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{m.parziali.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
{eventoId && !m.mvp ? (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return eventoId ? (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita/$id"
|
||||
params={{ id: eventoId }}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||
{contenuto}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: "classifica", label: "Classifica", contenuto: contenutoClassifica },
|
||||
{ id: "storico", label: "Storico partite", contenuto: contenutoStorico },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
+335
-362
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -103,6 +103,335 @@ function Squadra() {
|
||||
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
|
||||
: 0;
|
||||
|
||||
// Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`)
|
||||
// non deve ricalcolare badge, classifica e obiettivi delle altre sezioni,
|
||||
// che restano fuori schermo finché l'utente non cambia tab.
|
||||
const contenutoRosa = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{rosa.map((g) => {
|
||||
const stati = badgeGiocatore(g);
|
||||
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||
const isOpen = aperto === g.id;
|
||||
return (
|
||||
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto(isOpen ? null : g.id)}
|
||||
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Avatar
|
||||
id={g.id}
|
||||
fallback={g.numero ? String(g.numero) : g.iniziali}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={g.ruolo} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{g.numero ? `#${g.numero}` : "n° da definire"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{sbloccati.length > 0 ? (
|
||||
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
return (
|
||||
<span key={b.def.id} title={`${b.def.nome} — ${gradoMeta[b.grado!].label}`}>
|
||||
<Icon
|
||||
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{b.def.nome}: {gradoMeta[b.grado!].label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||
{g.nascita.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
|
||||
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.v}</p>
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{s.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
const meta = gradoMeta[b.grado!];
|
||||
return (
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
[rosa, aperto, mese],
|
||||
);
|
||||
|
||||
const contenutoStats = useMemo(
|
||||
() => (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
|
||||
<StatTile valore={matchGiocati} label="Match giocati" />
|
||||
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
|
||||
<StatTile valore={team.punti} label="Punti squadra" />
|
||||
<StatTile valore={team.ace} label="Ace squadra" />
|
||||
<StatTile valore={team.muri} label="Muri squadra" />
|
||||
</div>
|
||||
),
|
||||
[mediaPresenze, matchGiocati, pagelle, team],
|
||||
);
|
||||
|
||||
const contenutoClassifica = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroAperto(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="flex w-full min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5 text-left"
|
||||
>
|
||||
<Trophy className="h-4 w-4 shrink-0 text-warning" aria-hidden />
|
||||
<span className="shrink-0 text-sm text-foreground/80">Classifica per</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right text-sm font-bold">
|
||||
{criteri.find((c) => c.id === criterio)?.label}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
|
||||
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Classifica per</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6">
|
||||
{criteri.map((c) => (
|
||||
<DrawerClose key={c.id} asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCriterio(c.id)}
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
|
||||
criterio === c.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
</DrawerClose>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div className="space-y-2 px-5 pt-3">
|
||||
{ordinati.map((g, i) => (
|
||||
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{g.nome}
|
||||
{i === 0 ? <Crown className="ml-1 inline h-3.5 w-3.5 text-warning" /> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-display text-xl tabular-nums">
|
||||
{valore(g, criterio) || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Barra
|
||||
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
|
||||
altezza="h-1.5"
|
||||
trackClassName="mt-2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
[filtroAperto, criterio, ordinati, max],
|
||||
);
|
||||
|
||||
const contenutoObiettivi = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
Progresso collettivo
|
||||
</p>
|
||||
<p className="font-display text-4xl leading-none">
|
||||
<Numero valore={mediaObiettivi} suffisso="%" />
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
{completati}/{obiettivi.length} completati
|
||||
</p>
|
||||
</div>
|
||||
<Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{obiettivi.map((o, i) => {
|
||||
const pct = progressoObiettivo(o);
|
||||
const fatto = pct >= 100;
|
||||
return (
|
||||
<Reveal
|
||||
key={o.id}
|
||||
indice={i}
|
||||
className={cn(
|
||||
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
||||
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg leading-none">{o.emoji}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
fatto
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fatto ? "Completato" : `${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
[obiettivi, mediaObiettivi, completati],
|
||||
);
|
||||
|
||||
const contenutoBadge = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{badgeDefs.map((b) => {
|
||||
const Icon = b.icon;
|
||||
return (
|
||||
<BadgeDrawer key={b.id} def={b}>
|
||||
<div className="rounded-3xl bg-card p-3 shadow-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
||||
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{gradiOrdine.map((grado) => {
|
||||
const meta = gradoMeta[grado];
|
||||
const quanti = rosa.filter((g) => {
|
||||
const raggiunto = gradoRaggiunto(b, b.valore(g));
|
||||
return raggiunto
|
||||
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
|
||||
: false;
|
||||
}).length;
|
||||
return (
|
||||
<div
|
||||
key={grado}
|
||||
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
|
||||
>
|
||||
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
|
||||
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{quanti}/{rosa.length}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
[rosa],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
|
||||
@@ -111,372 +440,16 @@ function Squadra() {
|
||||
defaultId="rosa"
|
||||
variante="sottolineatura"
|
||||
voci={[
|
||||
{
|
||||
id: "rosa",
|
||||
label: "Rosa",
|
||||
contenuto: (
|
||||
<div className="space-y-2">
|
||||
{rosa.map((g) => {
|
||||
const stati = badgeGiocatore(g);
|
||||
const sbloccati = [
|
||||
...stati.filter((b) => b.grado !== null),
|
||||
...badgeSegretiSbloccati(g),
|
||||
];
|
||||
const isOpen = aperto === g.id;
|
||||
return (
|
||||
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto(isOpen ? null : g.id)}
|
||||
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Avatar
|
||||
id={g.id}
|
||||
fallback={g.numero ? String(g.numero) : g.iniziali}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">
|
||||
{g.nome}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={g.ruolo} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{g.numero ? `#${g.numero}` : "n° da definire"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{sbloccati.length > 0 ? (
|
||||
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
return (
|
||||
<span
|
||||
key={b.def.id}
|
||||
title={`${b.def.nome} — ${gradoMeta[b.grado!].label}`}
|
||||
>
|
||||
<Icon
|
||||
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{b.def.nome}: {gradoMeta[b.grado!].label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||
{g.nascita.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
|
||||
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.v}</p>
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{s.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/
|
||||
{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
const meta = gradoMeta[b.grado!];
|
||||
return (
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div
|
||||
className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-xs font-bold leading-tight">
|
||||
{b.def.nome}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-xs font-bold uppercase",
|
||||
meta.text,
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: "Statistiche",
|
||||
contenuto: (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
|
||||
<StatTile valore={matchGiocati} label="Match giocati" />
|
||||
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
|
||||
<StatTile valore={team.punti} label="Punti squadra" />
|
||||
<StatTile valore={team.ace} label="Ace squadra" />
|
||||
<StatTile valore={team.muri} label="Muri squadra" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
|
||||
{ id: "stats", label: "Statistiche", contenuto: contenutoStats },
|
||||
{
|
||||
id: "classifica-giocatori",
|
||||
label: "Classifica",
|
||||
aTuttoLarghezza: true,
|
||||
contenuto: (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroAperto(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="flex w-full min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5 text-left"
|
||||
>
|
||||
<Trophy className="h-4 w-4 shrink-0 text-warning" aria-hidden />
|
||||
<span className="shrink-0 text-sm text-foreground/80">Classifica per</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right text-sm font-bold">
|
||||
{criteri.find((c) => c.id === criterio)?.label}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
|
||||
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Classifica per</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6">
|
||||
{criteri.map((c) => (
|
||||
<DrawerClose key={c.id} asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCriterio(c.id)}
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
|
||||
criterio === c.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
</DrawerClose>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div className="space-y-2 px-5 pt-3">
|
||||
{ordinati.map((g, i) => (
|
||||
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{g.nome}
|
||||
{i === 0 ? (
|
||||
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
||||
) : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-display text-xl tabular-nums">
|
||||
{valore(g, criterio) || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Barra
|
||||
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
|
||||
altezza="h-1.5"
|
||||
trackClassName="mt-2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "obiettivi",
|
||||
label: "Obiettivi",
|
||||
contenuto: (
|
||||
<>
|
||||
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
Progresso collettivo
|
||||
</p>
|
||||
<p className="font-display text-4xl leading-none">
|
||||
<Numero valore={mediaObiettivi} suffisso="%" />
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
{completati}/{obiettivi.length} completati
|
||||
</p>
|
||||
</div>
|
||||
<Barra
|
||||
percentuale={mediaObiettivi}
|
||||
trackClassName="mt-3 bg-primary-foreground/15"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{obiettivi.map((o, i) => {
|
||||
const pct = progressoObiettivo(o);
|
||||
const fatto = pct >= 100;
|
||||
return (
|
||||
<Reveal
|
||||
key={o.id}
|
||||
indice={i}
|
||||
className={cn(
|
||||
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
||||
fatto
|
||||
? "ring-success/40"
|
||||
: pct >= 90
|
||||
? "ring-accent/40"
|
||||
: "ring-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg leading-none">{o.emoji}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
fatto
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fatto ? "Completato" : `${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(o)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "badge",
|
||||
label: "Badge",
|
||||
contenuto: (
|
||||
<div className="space-y-2">
|
||||
{badgeDefs.map((b) => {
|
||||
const Icon = b.icon;
|
||||
return (
|
||||
<BadgeDrawer key={b.id} def={b}>
|
||||
<div className="rounded-3xl bg-card p-3 shadow-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
||||
<p className="ml-auto text-xs text-muted-foreground">
|
||||
{descrizioneSoglie(b)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{gradiOrdine.map((grado) => {
|
||||
const meta = gradoMeta[grado];
|
||||
const quanti = rosa.filter((g) => {
|
||||
const raggiunto = gradoRaggiunto(b, b.valore(g));
|
||||
return raggiunto
|
||||
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
|
||||
: false;
|
||||
}).length;
|
||||
return (
|
||||
<div
|
||||
key={grado}
|
||||
className={cn(
|
||||
"rounded-2xl p-2 text-center ring-1",
|
||||
meta.bg,
|
||||
meta.ring,
|
||||
)}
|
||||
>
|
||||
<p className={cn("text-xs font-bold uppercase", meta.text)}>
|
||||
{meta.label}
|
||||
</p>
|
||||
<p className="font-display text-lg leading-none">
|
||||
{b.soglie[grado]}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{quanti}/{rosa.length}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
contenuto: contenutoClassifica,
|
||||
},
|
||||
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
||||
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
+20
-58
@@ -392,55 +392,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Superfici traslucide: se l'utente chiede meno trasparenza diventano piene. */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.materiale {
|
||||
background-color: var(--color-card);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
.materiale {
|
||||
background-color: var(--color-card);
|
||||
backdrop-filter: none;
|
||||
border-color: var(--color-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Chrome flottante in stile Apple: la barra è un materiale traslucido sotto
|
||||
* cui il contenuto scorre, con una sfumatura al bordo al posto della linea
|
||||
* netta (scroll edge effect).
|
||||
*/
|
||||
/*
|
||||
* Il materiale deve reggersi sul colore, non sul blur.
|
||||
* Vetro della barra flottante.
|
||||
*
|
||||
* Con un fondo poco denso (era 72%) l'aspetto dipendeva quasi tutto da
|
||||
* `backdrop-filter`, ed è lì che i due motori divergono: WebKit sfoca in modo
|
||||
* molto più denso e "latteo" di Blink, e `saturate` amplifica lo scarto. Il
|
||||
* risultato era una barra che su Safari sembrava piena e su Chrome Android
|
||||
* trasparente, a parità di CSS. A 85% il colore fa il lavoro e il blur è la
|
||||
* rifinitura: resta la profondità, sparisce quasi tutta la differenza.
|
||||
*
|
||||
* `in srgb` e non `in oklab`: mescolare con `transparent` — che è nero con
|
||||
* alpha 0 — dipende da come il motore premoltiplica, e in srgb è implementato
|
||||
* in modo molto più uniforme.
|
||||
*/
|
||||
@utility materiale {
|
||||
background-color: color-mix(in srgb, var(--color-card) 85%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
}
|
||||
|
||||
/*
|
||||
* Vetro della barra flottante. È `materiale` spinto: si vede attraverso.
|
||||
*
|
||||
* `materiale` sta all'85% di card perché è una superficie larga quanto lo
|
||||
* schermo, dove il divario di blur fra WebKit e Blink si nota (608f2ed). Qui
|
||||
* la superficie è una pillola piccola: si può scendere al 62% e lasciare che
|
||||
* sia il blur a fare il lavoro, che è la condizione perché si legga come vetro
|
||||
* invece che come plastica bianca.
|
||||
* Su una superficie larga quanto lo schermo un fondo all'85% di card serve a
|
||||
* tenere allineati WebKit e Blink, che sfocano in modo diverso (608f2ed); su
|
||||
* una pillola piccola come questa lo scarto non si legge, quindi si scende al
|
||||
* 62% e si lascia che sia il blur a fare il lavoro, condizione perché si
|
||||
* legga come vetro invece che come plastica bianca.
|
||||
*
|
||||
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in
|
||||
* alto (la luce che entra), filo scuro in basso (lo spessore del vetro), alone
|
||||
@@ -475,6 +434,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* `data-leggero` lo mette `BottomNav` quando `useMotoRidotto` rileva un device
|
||||
* debole (RAM bassa) o `prefers-reduced-motion`: la catena di filtri SVG sopra
|
||||
* (feTurbulence + feDisplacementMap, solo Blink/Android) è il costo maggiore
|
||||
* della barra fissa ad ogni cambio di rotta, quindi qui si torna al semplice
|
||||
* sfondo pieno. Specificità più alta di `.vetro` da sola: vince a prescindere
|
||||
* dall'ordine nel file.
|
||||
*/
|
||||
.vetro[data-leggero] {
|
||||
background-color: var(--color-card);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
|
||||
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
|
||||
.vetro {
|
||||
@@ -490,17 +463,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Chrome su Android disattiva `backdrop-filter` quando l'accelerazione
|
||||
* hardware non è disponibile: senza questo, su quei device la barra resta
|
||||
* solo semitrasparente e il contenuto si legge attraverso.
|
||||
*/
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.materiale {
|
||||
background-color: var(--color-card);
|
||||
}
|
||||
}
|
||||
|
||||
/** Padding inferiore della chrome flottante. */
|
||||
@utility pad-sicura-fondo {
|
||||
padding-bottom: var(--pad-sicura-fondo);
|
||||
|
||||
Reference in New Issue
Block a user