From ede02b7575ac707080daee7d30f2d6691b942abc Mon Sep 17 00:00:00 2001 From: Ivan Cacciari Date: Sat, 5 Sep 2026 21:18:19 +0200 Subject: [PATCH] Aggiunge sottosezioni a swipe su Squadra e Classifica. Co-authored-by: Cursor --- src/components/crapp/BarraSottosezioni.tsx | 119 ++++ src/routes/classifica.tsx | 259 +++++---- src/routes/squadra.tsx | 627 +++++++++++---------- 3 files changed, 593 insertions(+), 412 deletions(-) create mode 100644 src/components/crapp/BarraSottosezioni.tsx diff --git a/src/components/crapp/BarraSottosezioni.tsx b/src/components/crapp/BarraSottosezioni.tsx new file mode 100644 index 0000000..5ae5a22 --- /dev/null +++ b/src/components/crapp/BarraSottosezioni.tsx @@ -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>({}); + 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 ( +
+
+
+ {voci.map((v) => { + const selezionata = v.id === attiva; + return ( + + ); + })} +
+
+ +
+ + { + 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" + > +

{voce.label}

+ {voce.contenuto} +
+
+
+
+ ); +} diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index 9c1c823..c26e407 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -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"} /> -
-
- - {csi - ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` - : "Dati CSI in arrivo"} -
-
- -
-
- -
-
-
- # - Squadra - G - Set - Pt -
- {classifica.length === 0 ? ( -

- Classifica non ancora disponibile. -

- ) : ( - classifica.map((r) => { - const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; - return ( -
- - {r.pos} - - - {r.squadra} - - {r.giocate} - - {r.setFatti}:{r.setSubiti} - - {r.punti} + +
+ + {csi + ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` + : "Dati CSI in arrivo"}
- ); - }) - )} -
-
- - - {tuttiMatch.length === 0 ? ( -

- Nessun match disponibile. -

- ) : ( -
- {tuttiMatch.map((m) => { - const vinta = m.setNostri > m.setLoro; - const eventoId = eventoIdPerData.get(m.data); - const contenuto = ( - <> -
-
-

- {m.casa ? "CRAP Volley" : m.avversario} vs{" "} - {m.casa ? m.avversario : "CRAP Volley"} -

-

- {formatData(m.data)} · MVP {m.mvp || "da votare"} - {m.scout ? " · scoutata" : ""} -

-
- - {m.setNostri}-{m.setLoro} - +
+
+ # + Squadra + G + Set + Pt
-
- {m.parziali.map((p, i) => ( - p[1] ? "bg-secondary" : "bg-muted text-muted-foreground", - )} - > - {p[0]}-{p[1]} - - ))} - {eventoId && !m.mvp ? ( - - Vota MVP - - ) : null} -
- - ); - return eventoId ? ( - - {contenuto} - + {classifica.length === 0 ? ( +

+ Classifica non ancora disponibile. +

+ ) : ( + classifica.map((r) => { + const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; + return ( +
+ + {r.pos} + + + {r.squadra} + + + {r.giocate} + + + {r.setFatti}:{r.setSubiti} + + {r.punti} +
+ ); + }) + )} +
+ + ), + }, + { + id: "storico", + label: "Storico partite", + contenuto: + tuttiMatch.length === 0 ? ( +

+ Nessuna partita disponibile. +

) : ( -
- {contenuto} -
- ); - })} -
- )} - +
+ {tuttiMatch.map((m) => { + const vinta = m.setNostri > m.setLoro; + const eventoId = eventoIdPerData.get(m.data); + const contenuto = ( + <> +
+
+

+ {m.casa ? "CRAP Volley" : m.avversario} vs{" "} + {m.casa ? m.avversario : "CRAP Volley"} +

+

+ {formatData(m.data)} · MVP {m.mvp || "da votare"} + {m.scout ? " · scoutata" : ""} +

+
+ + {m.setNostri}-{m.setLoro} + +
+
+ {m.parziali.map((p, i) => ( + p[1] ? "bg-secondary" : "bg-muted text-muted-foreground", + )} + > + {p[0]}-{p[1]} + + ))} + {eventoId && !m.mvp ? ( + + Vota MVP + + ) : null} +
+ + ); + return eventoId ? ( + + {contenuto} + + ) : ( +
+ {contenuto} +
+ ); + })} +
+ ), + }, + ]} + /> ); } diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index a73ac88..8d36294 100644 --- a/src/routes/squadra.tsx +++ b/src/routes/squadra.tsx @@ -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() { <> -
-
- {rosa.map((g) => { - const stati = badgeGiocatore(g); - const sbloccati = [ - ...stati.filter((b) => b.grado !== null), - ...badgeSegretiSbloccati(g), - ]; - const isOpen = aperto === g.id; - return ( -
- + + {sbloccati.length > 0 ? ( + + {sbloccati.map((b) => { + const Icon = b.def.icon; + return ( + + + ); + })} + + ) : null} + + - {isOpen ? ( -
-
-

- {formatData(g.nascita)}{" "} - {g.nascita.slice(0, 4)} -

-
+ {isOpen ? ( +
+
+

+ {formatData(g.nascita)}{" "} + {g.nascita.slice(0, 4)} +

+
-
- {[ - { 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) => ( -
-

{s.v}

-

- {s.l} -

-
- ))} -
- -

- Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali} -

- {sbloccati.length === 0 ? ( -

- Nessun badge sbloccato per ora. -

- ) : ( -
- {sbloccati.map((b) => { - const Icon = b.def.icon; - const meta = gradoMeta[b.grado!]; - return ( - -
- -

{b.def.nome}

-

- {b.valore} {b.def.unita} - {b.prossimaSoglia - ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` - : ""} -

-

- {meta.label} +

+ {[ + { 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) => ( +
+

{s.v}

+

+ {s.l}

- - ); - })} -
- )} -
- ) : null} -
- ); - })} -
-
+ ))} +
-
-
- - - - - - -
-
- - -
- {criteri.map((c) => ( - - ))} -
-
- {ordinati.map((g, i) => ( -
-
-
- - {i + 1} - -
-

- {g.nome} - {i === 0 ? : null} -

-

- #{g.numero} · {g.ruolo} · {g.streak} presenze consecutive -

-
-
- - {valore(g, criterio) || "—"} - -
- -
- ))} -
-
- - -
-
-

- Progresso collettivo -

-

- -

-
-

- {completati}/{obiettivi.length} completati -

-
- - - } - > -
- {obiettivi.map((o, i) => { - const pct = progressoObiettivo(o); - const fatto = pct >= 100; - return ( - = 90 ? "ring-accent/40" : "ring-transparent", - )} - > -
- {o.emoji} -
-

{o.titolo}

-

{o.descrizione}

-
- - {fatto ? "Completato" : `${pct}%`} - -
- -

- {o.valore}/{o.target} {o.unita} · {pct}% - {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} -

-

{microcopyObiettivo(o)}

-

{o.impatto}

-
- ); - })} -
-
- - -
- {badgeDefs.map((b) => { - const Icon = b.icon; - return ( - -
-
- -

{b.nome}

-

{descrizioneSoglie(b)}

-
-
- {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 ( -
-

- {meta.label} +

+ Badge sbloccati · {collezioneBadge(g).ottenuti}/ + {collezioneBadge(g).totali}

-

{b.soglie[grado]}

-

- {quanti}/{rosa.length} + {sbloccati.length === 0 ? ( +

+ Nessun badge sbloccato per ora. +

+ ) : ( +
+ {sbloccati.map((b) => { + const Icon = b.def.icon; + const meta = gradoMeta[b.grado!]; + return ( + +
+ +

+ {b.def.nome} +

+

+ {b.valore} {b.def.unita} + {b.prossimaSoglia + ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` + : ""} +

+

+ {meta.label} +

+
+
+ ); + })} +
+ )} +
+ ) : null} + + ); + })} +
+ ), + }, + { + id: "stats", + label: "Statistiche", + contenuto: ( +
+ + + + + + +
+ ), + }, + { + id: "classifica-giocatori", + label: "Classifica", + contenuto: ( + <> +
+ {criteri.map((c) => ( + + ))} +
+
+ {ordinati.map((g, i) => ( +
+
+
+ + {i + 1} + +
+

+ {g.nome} + {i === 0 ? ( + + ) : null} +

+

+ #{g.numero} · {g.ruolo} · {g.streak} presenze consecutive +

+
+
+ + {valore(g, criterio) || "—"} + +
+ +
+ ))} +
+ + ), + }, + { + id: "obiettivi", + label: "Obiettivi", + contenuto: ( + <> +
+
+
+

+ Progresso collettivo +

+

+ +

+
+

+ {completati}/{obiettivi.length} completati +

+
+ +
+
+ {obiettivi.map((o, i) => { + const pct = progressoObiettivo(o); + const fatto = pct >= 100; + return ( + = 90 + ? "ring-accent/40" + : "ring-transparent", + )} + > +
+ {o.emoji} +
+

{o.titolo}

+

{o.descrizione}

+
+ + {fatto ? "Completato" : `${pct}%`} + +
+ +

+ {o.valore}/{o.target} {o.unita} · {pct}% + {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} +

+

+ {microcopyObiettivo(o)} +

+

{o.impatto}

+
+ ); + })} +
+ + ), + }, + { + id: "badge", + label: "Badge", + contenuto: ( +
+ {badgeDefs.map((b) => { + const Icon = b.icon; + return ( + +
+
+ +

{b.nome}

+

+ {descrizioneSoglie(b)}

- ); - })} -
-
- - ); - })} -
- +
+ {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 ( +
+

+ {meta.label} +

+

+ {b.soglie[grado]} +

+

+ {quanti}/{rosa.length} +

+
+ ); + })} +
+
+ + ); + })} + + ), + }, + ]} + /> ); }