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>
);
}
+27 -18
View File
@@ -2,11 +2,11 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { ChevronRight, RefreshCw } from "lucide-react"; import { ChevronRight, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatData } from "@/lib/crapp-data"; 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 { useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { useEventi } from "@/lib/eventi"; import { useEventi } from "@/lib/eventi";
@@ -69,19 +69,20 @@ function Classifica() {
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"} sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
/> />
<div className="px-5 pt-4"> <BarraSottosezioni
<div className="flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground"> 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" /> <RefreshCw className="h-3.5 w-3.5 text-accent" />
{csi {csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"} : "Dati CSI in arrivo"}
</div> </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="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"> <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>#</span>
@@ -111,7 +112,9 @@ function Classifica() {
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}> <span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
{r.squadra} {r.squadra}
</span> </span>
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span> <span className="text-center text-xs text-muted-foreground">
{r.giocate}
</span>
<span className="text-center text-xs tabular-nums text-muted-foreground"> <span className="text-center text-xs tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti} {r.setFatti}:{r.setSubiti}
</span> </span>
@@ -121,12 +124,16 @@ function Classifica() {
}) })
)} )}
</div> </div>
</Section> </>
),
<SezioneTendina titolo="Storico match"> },
{tuttiMatch.length === 0 ? ( {
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"> <p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessun match disponibile. Nessuna partita disponibile.
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -193,8 +200,10 @@ function Classifica() {
); );
})} })}
</div> </div>
)} ),
</SezioneTendina> },
]}
/>
</> </>
); );
} }
+84 -31
View File
@@ -2,7 +2,8 @@ import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown } from "lucide-react"; import { Cake, ChevronDown, Crown } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { Avatar } from "@/components/crapp/Avatar";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
@@ -99,7 +100,13 @@ function Squadra() {
<> <>
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} /> <PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
<Section titolo="Rosa"> <BarraSottosezioni
defaultId="rosa"
voci={[
{
id: "rosa",
label: "Rosa",
contenuto: (
<div className="space-y-2"> <div className="space-y-2">
{rosa.map((g) => { {rosa.map((g) => {
const stati = badgeGiocatore(g); const stati = badgeGiocatore(g);
@@ -122,7 +129,9 @@ function Squadra() {
className="h-11 w-11 text-lg" className="h-11 w-11 text-lg"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span> <span className="block truncate text-sm font-bold leading-tight">
{g.nome}
</span>
<span className="mt-1 flex items-center gap-2"> <span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} /> <RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -187,7 +196,8 @@ function Squadra() {
</div> </div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali} Badge sbloccati · {collezioneBadge(g).ottenuti}/
{collezioneBadge(g).totali}
</p> </p>
{sbloccati.length === 0 ? ( {sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground"> <p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
@@ -200,16 +210,25 @@ function Squadra() {
const meta = gradoMeta[b.grado!]; const meta = gradoMeta[b.grado!];
return ( return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}> <BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}> <div
className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}
>
<Icon className={cn("h-4 w-4", meta.text)} /> <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="mt-1 text-xs font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita} {b.valore} {b.def.unita}
{b.prossimaSoglia {b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""} : ""}
</p> </p>
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}> <p
className={cn(
"mt-1.5 text-xs font-bold uppercase",
meta.text,
)}
>
{meta.label} {meta.label}
</p> </p>
</div> </div>
@@ -224,9 +243,12 @@ function Squadra() {
); );
})} })}
</div> </div>
</Section> ),
},
<Section titolo="Statistiche di squadra"> {
id: "stats",
label: "Statistiche",
contenuto: (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" /> <StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={matchGiocati} label="Match giocati" /> <StatTile valore={matchGiocati} label="Match giocati" />
@@ -235,9 +257,13 @@ function Squadra() {
<StatTile valore={team.ace} label="Ace squadra" /> <StatTile valore={team.ace} label="Ace squadra" />
<StatTile valore={team.muri} label="Muri squadra" /> <StatTile valore={team.muri} label="Muri squadra" />
</div> </div>
</Section> ),
},
<SezioneTendina titolo="Classifica giocatori"> {
id: "classifica-giocatori",
label: "Classifica",
contenuto: (
<>
<div className="mb-3 flex flex-nowrap gap-1.5"> <div className="mb-3 flex flex-nowrap gap-1.5">
{criteri.map((c) => ( {criteri.map((c) => (
<button <button
@@ -267,7 +293,9 @@ function Squadra() {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-bold"> <p className="truncate text-sm font-bold">
{g.nome} {g.nome}
{i === 0 ? <Crown className="ml-1 inline h-3.5 w-3.5 text-warning" /> : null} {i === 0 ? (
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
) : null}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive #{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
@@ -286,11 +314,14 @@ function Squadra() {
</div> </div>
))} ))}
</div> </div>
</SezioneTendina> </>
),
<SezioneTendina },
titolo="Obiettivi di squadra" {
anteprima={ id: "obiettivi",
label: "Obiettivi",
contenuto: (
<>
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card"> <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 className="flex items-end justify-between gap-3">
<div> <div>
@@ -305,10 +336,11 @@ function Squadra() {
{completati}/{obiettivi.length} completati {completati}/{obiettivi.length} completati
</p> </p>
</div> </div>
<Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" /> <Barra
percentuale={mediaObiettivi}
trackClassName="mt-3 bg-primary-foreground/15"
/>
</div> </div>
}
>
<div className="space-y-2"> <div className="space-y-2">
{obiettivi.map((o, i) => { {obiettivi.map((o, i) => {
const pct = progressoObiettivo(o); const pct = progressoObiettivo(o);
@@ -319,7 +351,11 @@ function Squadra() {
indice={i} indice={i}
className={cn( className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1", "premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent", fatto
? "ring-success/40"
: pct >= 90
? "ring-accent/40"
: "ring-transparent",
)} )}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -344,15 +380,21 @@ function Squadra() {
{o.valore}/{o.target} {o.unita} · {pct}% {o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p> </p>
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</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> <p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
</Reveal> </Reveal>
); );
})} })}
</div> </div>
</SezioneTendina> </>
),
<SezioneTendina titolo="Badge sbloccabili"> },
{
id: "badge",
label: "Badge",
contenuto: (
<div className="space-y-2"> <div className="space-y-2">
{badgeDefs.map((b) => { {badgeDefs.map((b) => {
const Icon = b.icon; const Icon = b.icon;
@@ -362,7 +404,9 @@ function Squadra() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" /> <Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p> <p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p> <p className="ml-auto text-xs text-muted-foreground">
{descrizioneSoglie(b)}
</p>
</div> </div>
<div className="mt-2 grid grid-cols-3 gap-1.5"> <div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => { {gradiOrdine.map((grado) => {
@@ -376,12 +420,18 @@ function Squadra() {
return ( return (
<div <div
key={grado} key={grado}
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)} className={cn(
"rounded-2xl p-2 text-center ring-1",
meta.bg,
meta.ring,
)}
> >
<p className={cn("text-xs font-bold uppercase", meta.text)}> <p className={cn("text-xs font-bold uppercase", meta.text)}>
{meta.label} {meta.label}
</p> </p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p> <p className="font-display text-lg leading-none">
{b.soglie[grado]}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{quanti}/{rosa.length} {quanti}/{rosa.length}
</p> </p>
@@ -394,7 +444,10 @@ function Squadra() {
); );
})} })}
</div> </div>
</SezioneTendina> ),
},
]}
/>
</> </>
); );
} }