Aggiunge sottosezioni a swipe su Squadra e Classifica.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Cacciari
2026-09-05 21:18:19 +02:00
co-authored by Cursor
parent c089d9e8ba
commit ede02b7575
3 changed files with 593 additions and 412 deletions
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { molla, proietta } from "@/lib/molla";
export type VoceSottosezione = {
id: string;
label: string;
contenuto: ReactNode;
};
/**
* Barra di sottosezioni in un'unica fila scorrevole (swipe orizzontale) e
* pannello che mostra una sola sezione alla volta, cambiabile anche con
* swipe sul contenuto.
*/
export function BarraSottosezioni({
voci,
defaultId,
}: {
voci: VoceSottosezione[];
defaultId?: string;
}) {
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const ridotto = useReducedMotion();
const indice = Math.max(
0,
voci.findIndex((v) => v.id === attiva),
);
const voce = voci[indice] ?? voci[0];
useEffect(() => {
tabRefs.current[attiva]?.scrollIntoView({
behavior: ridotto ? "auto" : "smooth",
inline: "center",
block: "nearest",
});
}, [attiva, ridotto]);
function vaiA(nuovo: number) {
if (nuovo < 0 || nuovo >= voci.length) return;
const target = voci[nuovo];
if (!target || target.id === attiva) return;
direzione.current = nuovo > indice ? 1 : -1;
setAttiva(target.id);
}
if (!voce) return null;
return (
<div>
<div className="border-b border-border bg-background/80 pt-3 backdrop-blur-md">
<div
role="tablist"
aria-label="Sottosezioni"
className="-mx-0 flex snap-x snap-mandatory gap-1.5 overflow-x-auto px-5 pb-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{voci.map((v) => {
const selezionata = v.id === attiva;
return (
<button
key={v.id}
ref={(el) => {
tabRefs.current[v.id] = el;
}}
type="button"
role="tab"
aria-selected={selezionata}
onClick={() => {
const i = voci.findIndex((x) => x.id === v.id);
vaiA(i);
}}
className={cn(
"snap-center shrink-0 rounded-full px-3.5 py-2 text-xs font-bold uppercase tracking-wide transition-colors",
"min-h-11 touch-manipulation whitespace-nowrap",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
)}
>
{v.label}
</button>
);
})}
</div>
</div>
<div className="relative overflow-hidden">
<AnimatePresence initial={false} mode="popLayout" custom={direzione.current}>
<motion.div
key={voce.id}
role="tabpanel"
aria-label={voce.label}
custom={direzione.current}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) vaiA(indice + 1);
else if (arrivo > 60) vaiA(indice - 1);
}}
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione.current * 40 }}
animate={{ opacity: 1, x: 0 }}
exit={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione.current * -40 }}
transition={ridotto ? { duration: 0.15 } : molla.foglio}
className="touch-pan-y px-5 py-4"
>
<h2 className="mb-3 font-display-sm text-lg uppercase">{voce.label}</h2>
{voce.contenuto}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
+134 -125
View File
@@ -2,11 +2,11 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { ChevronRight, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatData } from "@/lib/crapp-data";
import { PageHeader, Section, SezioneTendina } from "@/components/crapp/ui-bits";
import { PageHeader } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { useEventi } from "@/lib/eventi";
@@ -69,132 +69,141 @@ function Classifica() {
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
/>
<div className="px-5 pt-4">
<div className="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="mt-2">
<ScoutEntry variante="compatto" />
</div>
</div>
<Section titolo="Classifica">
<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>
<BarraSottosezioni
defaultId="classifica"
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>
</Section>
<SezioneTendina titolo="Storico match">
{tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessun match 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 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>
<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>
{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>
) : (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
);
})}
</div>
)}
</SezioneTendina>
<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>
),
},
]}
/>
</>
);
}
+340 -287
View File
@@ -2,7 +2,8 @@ import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, Section, SezioneTendina } from "@/components/crapp/ui-bits";
import { PageHeader } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
@@ -99,302 +100,354 @@ function Squadra() {
<>
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
<Section titolo="Rosa">
<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}
<BarraSottosezioni
defaultId="rosa"
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>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
</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>
{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}
<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>
</BadgeDrawer>
);
})}
</div>
)}
</div>
) : null}
</article>
);
})}
</div>
</Section>
))}
</div>
<Section titolo="Statistiche di squadra">
<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>
</Section>
<SezioneTendina titolo="Classifica giocatori">
<div className="mb-3 flex flex-nowrap gap-1.5">
{criteri.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCriterio(c.id)}
aria-pressed={criterio === c.id}
className={cn(
"min-h-11 min-w-0 flex-1 truncate rounded-full px-1.5 text-center text-xs font-bold uppercase transition-colors",
criterio === c.id
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
)}
>
{c.label}
</button>
))}
</div>
<div className="space-y-2">
{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-xl 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>
</SezioneTendina>
<SezioneTendina
titolo="Obiettivi di squadra"
anteprima={
<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>
</SezioneTendina>
<SezioneTendina titolo="Badge sbloccabili">
<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 className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/
{collezioneBadge(g).totali}
</p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
<p className="text-xs text-muted-foreground">
{quanti}/{rosa.length}
{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: "classifica-giocatori",
label: "Classifica",
contenuto: (
<>
<div className="mb-3 flex flex-nowrap gap-1.5">
{criteri.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCriterio(c.id)}
aria-pressed={criterio === c.id}
className={cn(
"min-h-11 min-w-0 flex-1 truncate rounded-full px-1.5 text-center text-xs font-bold uppercase transition-colors",
criterio === c.id
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
)}
>
{c.label}
</button>
))}
</div>
<div className="space-y-2">
{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-xl 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>
</div>
</BadgeDrawer>
);
})}
</div>
</SezioneTendina>
<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>
),
},
]}
/>
</>
);
}