Initial independent version of CrAPP

This commit is contained in:
Ivan Cacciari
2026-08-06 08:36:47 +02:00
commit 330669176d
167 changed files with 24096 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Routes
TanStack Start uses **file-based routing**. Every `.tsx` file in this directory
defines a route. Do **not** create `src/pages/`, `src/routes/_app/index.tsx`, or
`app/layout.tsx` — those are Next.js / Remix conventions. The only root layout
is `src/routes/__root.tsx`.
## Conventions
| File | URL |
| --- | --- |
| `index.tsx` | `/` |
| `about.tsx` | `/about` |
| `users/index.tsx` | `/users` |
| `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) |
| `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) |
| `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) |
| `_layout.tsx` | layout route (renders children via `<Outlet />`) |
| `__root.tsx` | app shell — wraps every page; preserve `<Outlet />` |
`routeTree.gen.ts` is auto-generated. Don't edit it by hand.
+177
View File
@@ -0,0 +1,177 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
Outlet,
Link,
createRootRouteWithContext,
useRouter,
useNavigate,
useLocation,
HeadContent,
Scripts,
} from "@tanstack/react-router";
import { useEffect, useState, type ReactNode } from "react";
import appCss from "../styles.css?url";
import { reportLovableError } from "../lib/lovable-error-reporting";
import { BottomNav } from "../components/crapp/BottomNav";
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
import { Toaster } from "../components/ui/sonner";
import { TeamLogo } from "../components/crapp/ui-bits";
import { useGiocatoreBase } from "../lib/user-store";
function NotFoundComponent() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-7xl font-bold text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Page not found</h2>
<p className="mt-2 text-sm text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
</p>
<div className="mt-6">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Go home
</Link>
</div>
</div>
</div>
);
}
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
useEffect(() => {
reportLovableError(error, { boundary: "tanstack_root_error_component" });
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
This page didn't load
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Something went wrong on our end. You can try refreshing or head back home.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<button
onClick={() => {
router.invalidate();
reset();
}}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Try again
</button>
<a
href="/"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
Go home
</a>
</div>
</div>
</div>
);
}
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "CrAPP — L'app del CRAP Volley" },
{
name: "description",
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
},
{ name: "author", content: "CRAP Volley" },
{ name: "theme-color", content: "#111111" },
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
{
property: "og:description",
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:site", content: "@Lovable" },
{ name: "twitter:title", content: "CrAPP — L'app del CRAP Volley" },
{ name: "twitter:description", content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile." },
{ property: "og:image", content: "https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png" },
{ name: "twitter:image", content: "https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png" },
],
links: [
{
rel: "stylesheet",
href: appCss,
},
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:wght@400;500;600;700;800&display=swap",
},
{ rel: "icon", href: "/favicon.png", type: "image/png" },
{ rel: "apple-touch-icon", href: "/icon-192.png", sizes: "192x192" },
{ rel: "manifest", href: "/manifest.webmanifest" },
],
}),
shellComponent: RootShell,
component: RootComponent,
notFoundComponent: NotFoundComponent,
errorComponent: ErrorComponent,
});
function RootShell({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
function RootComponent() {
const { queryClient } = Route.useRouteContext();
const navigate = useNavigate();
const location = useLocation();
const giocatore = useGiocatoreBase();
const [mounted, setMounted] = useState(false);
const isBenvenuto = location.pathname === "/benvenuto";
useEffect(() => {
setMounted(true);
if (!giocatore && !isBenvenuto) {
navigate({ to: "/benvenuto" });
}
}, [giocatore, isBenvenuto, navigate]);
if (!mounted) {
return (
<div className="grid min-h-screen place-items-center bg-background">
<TeamLogo className="h-16 w-16 animate-pulse" />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<div className="mx-auto min-h-screen max-w-md bg-background pb-24">
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
<Outlet />
</div>
{!isBenvenuto && <BottomNav />}
{!isBenvenuto && <CelebrazioneBadge />}
<Toaster position="top-center" duration={3500} closeButton />
</QueryClientProvider>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, MapPin, Clock, Users, Dumbbell, CalendarDays } from "lucide-react";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData, giocatori } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
import { usePresenzeEvento } from "@/lib/presenze";
export const Route = createFileRoute("/allenamento/$id")({
head: () => {
const titolo = "Dettaglio allenamento";
return {
meta: [
{ title: `${titolo} — CrAPP` },
{ name: "description", content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley." },
{ property: "og:title", content: `${titolo} — CrAPP` },
{ property: "og:description", content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley." },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
],
};
},
component: AllenamentoDetail,
});
function AllenamentoDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const { risposte } = usePresenzeEvento(id);
const presentiVeri = giocatori.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
).length;
if (!evento) {
return (
<div className="px-5 pt-8">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Torna al calendario
</Link>
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
</div>
);
}
return (
<>
<div className="px-5 pt-4">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Calendario
</Link>
</div>
<PageHeader titolo="Allenamento" sottotitolo={formatData(evento.data)} />
<Section titolo={evento.titolo}>
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center gap-3">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-primary/15 text-primary">
<Dumbbell className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Allenamento di squadra
</p>
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
</div>
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento).length}
</div>
<TurnoPalloni eventoId={evento.id} />
</div>
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} />
</Section>
</>
);
}
@@ -0,0 +1,59 @@
import { createFileRoute } from "@tanstack/react-router";
import { completaTurni, eventiPalloni, eventoPrecedente, oggiISO } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server";
import { leggiEventi } from "@/lib/eventi.server";
export const Route = createFileRoute("/api/public/promemoria-palloni")({
server: {
handlers: {
POST: async () => {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin
.from("turni_palloni")
.select("evento_id, giocatore_id");
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const turni = completaTurni(salvati, eventi);
const oggi = oggiISO();
const destinatari = new Set<string>();
for (const evento of eventiPalloni(eventi)) {
if (evento.data !== oggi) continue;
const incaricato = turni[evento.id];
if (incaricato) destinatari.add(incaricato);
const prima = eventoPrecedente(eventi, evento.id);
const precedente = prima ? turni[prima.id] : undefined;
if (precedente) destinatari.add(precedente);
}
if (destinatari.size === 0) return Response.json({ inviate: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id")
.in("giocatore_id", [...destinatari]);
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
const stato = await inviaPush(iscrizione.endpoint);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("promemoria-palloni", error);
}
}
return Response.json({ inviate });
},
},
},
});
+12
View File
@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/public/push-config")({
server: {
handlers: {
GET: async () => {
const publicKey = process.env["VAPID_PUBLIC_KEY"] ?? null;
return Response.json({ publicKey });
},
},
},
});
+84
View File
@@ -0,0 +1,84 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { formatData, giocatori } from "@/lib/crapp-data";
import {
completaTurni,
eventiPalloni,
eventoPrecedente,
eventoSuccessivo,
oggiISO,
} from "@/lib/palloni-core";
import { leggiEventi } from "@/lib/eventi.server";
const schema = z.object({ endpoint: z.string().url().max(1000) });
export const Route = createFileRoute("/api/public/push-messaggio")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
// Messaggio in coda (es. sollecito presenze): ha la precedenza e viene consumato.
const { data: promemoria } = await supabaseAdmin
.from("promemoria_push")
.select("id, titolo, testo")
.eq("endpoint", parsed.data.endpoint)
.order("creato_il", { ascending: false })
.limit(1)
.maybeSingle();
if (promemoria) {
await supabaseAdmin.from("promemoria_push").delete().eq("endpoint", parsed.data.endpoint);
return Response.json({ title: promemoria.titolo, body: promemoria.testo });
}
const { data: iscrizione } = await supabaseAdmin
.from("push_subscriptions")
.select("giocatore_id")
.eq("endpoint", parsed.data.endpoint)
.maybeSingle();
if (!iscrizione) return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
const { data: righe } = await supabaseAdmin
.from("turni_palloni")
.select("evento_id, giocatore_id");
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const turni = completaTurni(salvati, eventi);
const oggi = oggiISO();
const mioId = iscrizione.giocatore_id;
const nome = giocatori.find((g) => g.id === mioId)?.nome ?? "";
for (const evento of eventiPalloni(eventi)) {
if (evento.data !== oggi) continue;
const prima = eventoPrecedente(eventi, evento.id);
if (prima && turni[prima.id] === mioId) {
return Response.json({
title: "Porta i palloni oggi",
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
});
}
if (turni[evento.id] === mioId) {
const dopo = eventoSuccessivo(eventi, evento.id);
return Response.json({
title: "Tocca a te prendere i palloni",
body: dopo
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
: `A fine ${evento.titolo} porta a casa i palloni.`,
});
}
}
return Response.json({
title: "CrAPP · Turno palloni",
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
});
},
},
},
});
+46
View File
@@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const schemaIscrizione = z.object({
endpoint: z.string().url().max(1000),
giocatoreId: z.string().min(1).max(50),
p256dh: z.string().min(1).max(500),
auth: z.string().min(1).max(500),
});
const schemaCancellazione = z.object({ endpoint: z.string().url().max(1000) });
export const Route = createFileRoute("/api/public/push-subscribe")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schemaIscrizione.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { error } = await supabaseAdmin.from("push_subscriptions").upsert(
{
endpoint: parsed.data.endpoint,
giocatore_id: parsed.data.giocatoreId,
p256dh: parsed.data.p256dh,
auth: parsed.data.auth,
},
{ onConflict: "endpoint" },
);
if (error) {
console.error("push-subscribe", error);
return new Response("Errore salvataggio", { status: 500 });
}
return Response.json({ ok: true });
},
DELETE: async ({ request }) => {
const parsed = schemaCancellazione.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
await supabaseAdmin.from("push_subscriptions").delete().eq("endpoint", parsed.data.endpoint);
return Response.json({ ok: true });
},
},
},
});
@@ -0,0 +1,74 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { formatData, giocatori } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
eventoId: z.string().min(1).max(50),
da: z.string().min(1).max(60).optional(),
});
export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const eventi = await leggiEventi();
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
if (!evento) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin
.from("risposte_presenze")
.select("giocatore_id, stato")
.eq("evento_id", evento.id);
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
const destinatari = giocatori
.filter((g) => {
const stato = stati.get(g.id);
return stato === undefined || stato === "forse";
})
.map((g) => g.id);
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id")
.in("giocatore_id", destinatari);
const titolo = "Manca la tua risposta";
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
} una conferma: presente, assente o in ritardo?`;
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
await supabaseAdmin
.from("promemoria_push")
.insert({ endpoint: iscrizione.endpoint, titolo, testo });
const stato = await inviaPush(iscrizione.endpoint);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("sollecita-presenze", error);
}
}
return Response.json({ inviate, destinatari: destinatari.length });
},
},
},
});
+66
View File
@@ -0,0 +1,66 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { TeamLogo } from "@/components/crapp/ui-bits";
import { giocatori } from "@/lib/crapp-data";
import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
export const Route = createFileRoute("/benvenuto")({
head: () => ({
meta: [
{ title: "Benvenuto — CrAPP" },
{
name: "description",
content: "Seleziona il tuo profilo giocatore per iniziare.",
},
{ property: "og:title", content: "Benvenuto — CrAPP" },
{
property: "og:description",
content: "Seleziona il tuo profilo giocatore per iniziare.",
},
],
}),
component: Benvenuto,
});
function Benvenuto() {
const navigate = useNavigate();
const giocatore = useGiocatoreCorrente();
useEffect(() => {
if (giocatore) {
navigate({ to: "/" });
}
}, [giocatore, navigate]);
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
<TeamLogo className="h-20 w-20" />
<h1 className="mt-6 text-center font-display text-4xl uppercase leading-none">
Benvenuto in CrAPP
</h1>
<p className="mt-2 text-center text-sm text-muted-foreground">
Seleziona chi sei per personalizzare l'app.
</p>
<div className="mt-8 w-full max-w-sm space-y-2">
{giocatori.map((g) => (
<button
key={g.id}
type="button"
onClick={() => impostaGiocatore(g.id)}
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
>
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
{g.iniziali}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="font-semibold leading-tight">{g.nome}</p>
<p className="text-xs text-muted-foreground">
#{g.numero} · {g.ruolo}
</p>
</div>
</button>
))}
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
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 { cn } from "@/lib/utils";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { isAdmin } from "@/lib/crapp-data";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export const Route = createFileRoute("/calendario")({
head: () => ({
meta: [
{ title: "Calendario squadra — CrAPP" },
{
name: "description",
content: "Allenamenti, partite ed eventi extra del CRAP Volley con vista mensile e lista.",
},
{ property: "og:title", content: "Calendario squadra — CrAPP" },
{
property: "og:description",
content: "Vista mensile e lista eventi con promemoria per la squadra.",
},
],
}),
component: Calendario,
});
const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
const mesiIT = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
] as const;
function useMeseNav(initial = { anno: 2026, mese: 7 }) {
const [anno, setAnno] = useState(initial.anno);
const [mese, setMese] = useState(initial.mese);
const precedente = () => {
if (mese === 0) {
setMese(11);
setAnno((a) => a - 1);
} else {
setMese((m) => m - 1);
}
};
const successivo = () => {
if (mese === 11) {
setMese(0);
setAnno((a) => a + 1);
} else {
setMese((m) => m + 1);
}
};
return { anno, mese, precedente, successivo };
}
function giorniDelMese(anno: number, mese: number) {
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
const offsetLunedi = (primoGiorno + 6) % 7;
return { giorni, offsetLunedi };
}
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function Calendario() {
const [vista, setVista] = useState<"mese" | "lista">("mese");
// SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
useEffect(() => {
const d = new Date();
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false);
const io = useGiocatoreCorrente();
const { eventi } = useEventi();
const { anno, mese, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
const compleanni = compleanniEventi(anno);
const compleanniMese = compleanni.filter((c) => c.data.startsWith(mesePrefix));
const eventiMese = eventi.filter((e) => e.data.startsWith(mesePrefix));
const eventiPerGiorno = new Map<number, Evento[]>();
for (const e of [...compleanniMese, ...eventiMese]) {
const g = Number(e.data.slice(8, 10));
const lista = eventiPerGiorno.get(g) ?? [];
lista.push(e);
eventiPerGiorno.set(g, lista);
}
function apriGiorno(giorno: number) {
if (!eventiPerGiorno.has(giorno)) return;
setGiornoSelezionato(giorno);
setDrawerAperto(true);
}
const eventiGiornoSelezionato = giornoSelezionato ? eventiPerGiorno.get(giornoSelezionato) ?? [] : [];
return (
<>
<PageHeader titolo="Calendario" sottotitolo={`${mesiIT[mese]} ${anno} · Stagione 2026/27`} />
<div className="px-5 pt-4">
<div className="flex rounded-full bg-secondary p-1">
{(["mese", "lista"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setVista(v)}
className={cn(
"flex-1 rounded-full py-2 text-xs font-bold uppercase tracking-wide transition-colors",
vista === v ? "bg-card shadow-card text-foreground" : "text-muted-foreground",
)}
>
{v === "mese" ? "Vista mese" : "Lista eventi"}
</button>
))}
</div>
</div>
{vista === "mese" ? (
<Section titolo={mesiIT[mese]!}>
<div className="rounded-3xl bg-card p-4 shadow-card">
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={precedente}
className="grid h-9 w-9 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese precedente"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="font-display text-xl uppercase tracking-wide">
{mesiIT[mese]} {anno}
</span>
<button
type="button"
onClick={successivo}
className="grid h-9 w-9 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese successivo"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-[11px] font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
</div>
<div className="mt-2 grid grid-cols-7 gap-1">
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
const tipiGiorno = Array.from(new Set(eventiGiorno.map((e) => e.tipo)));
const coloreTipo: Record<Evento["tipo"], string> = {
partita: "var(--accent)",
allenamento: "var(--training)",
evento: "var(--warning)",
compleanno: "var(--success)",
};
const sfondo =
tipiGiorno.length > 1
? `linear-gradient(135deg, ${tipiGiorno
.map((t, idx) => {
const da = (idx / tipiGiorno.length) * 100;
const a = ((idx + 1) / tipiGiorno.length) * 100;
return `${coloreTipo[t]} ${da}%, ${coloreTipo[t]} ${a}%`;
})
.join(", ")})`
: undefined;
const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined;
const isOggi = !!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
const Cella = haEventi ? "button" : "div";
return (
<Cella
key={giorno}
type={haEventi ? "button" : undefined}
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
style={sfondo ? { backgroundImage: sfondo } : undefined}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold",
tipo === "partita" && "bg-accent text-accent-foreground",
tipo === "allenamento" && "bg-training text-training-foreground",
tipo === "evento" && "bg-warning text-warning-foreground",
tipo === "compleanno" && "bg-success text-success-foreground",
!tipo && !haEventi && "text-muted-foreground",
!tipo && haEventi && "text-foreground",
haEventi && "cursor-pointer transition-transform active:scale-90",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={haEventi ? `Eventi del ${giorno}` : undefined}
aria-current={isOggi ? "date" : undefined}
>
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">{giorno}</span>
</Cella>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-3 text-[11px] font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-training" /> Allenamento
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-warning" /> Eventi
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-success" /> Compleanni
</span>
</div>
</div>
</Section>
) : null}
{io && isAdmin(io.id) ? (
<div className="px-5 pt-4">
<Link
to="/eventi"
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
</Link>
</div>
) : null}
<Section titolo="Prossimi eventi">
<div className="space-y-3">
{eventiMese.length > 0 ? (
eventiMese.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun evento in {mesiIT[mese]!.toLowerCase()}
</p>
)}
</div>
</Section>
<Section titolo="Compleanni">
<div className="space-y-3">
{compleanniMese.length > 0 ? (
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />)
) : (
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
</p>
)}
</div>
</Section>
<Drawer open={drawerAperto} onOpenChange={setDrawerAperto}>
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
<DrawerHeader className="relative px-0 pb-2 text-left">
<DrawerTitle className="font-display text-2xl uppercase tracking-wide">
{giornoSelezionato ? `${giornoSelezionato} ${mesiIT[mese]}` : "Eventi"}
</DrawerTitle>
<DrawerClose className="absolute right-0 top-1 grid h-8 w-8 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
<X className="h-4 w-4" />
<span className="sr-only">Chiudi</span>
</DrawerClose>
</DrawerHeader>
<div className="max-h-[60vh] space-y-3 overflow-y-auto py-2">
{eventiGiornoSelezionato.length > 0 ? (
eventiGiornoSelezionato.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun evento in questa data
</p>
)}
</div>
</DrawerContent>
</Drawer>
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { storicoMatch } from "@/lib/crapp-data";
import { classificaConScout, useScoutMatches } from "@/lib/scout-store";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
export const Route = createFileRoute("/classifica")({
head: () => ({
meta: [
{ title: "Classifica campionato — CrAPP" },
{
name: "description",
content: "Classifica e risultati del girone CSI seguiti in tempo reale dal CRAP Volley.",
},
{ property: "og:title", content: "Classifica campionato — CrAPP" },
{ property: "og:description", content: "Posizioni, set e risultati aggiornati del girone." },
],
}),
component: Classifica,
});
function Classifica() {
const scoutMatches = useScoutMatches();
const classifica = classificaConScout(scoutMatches);
const risultati = [
...scoutMatches.map((m) => ({
id: m.id,
avversario: m.avversario,
casa: m.casa,
setNostri: m.setNostri,
setLoro: m.setLoro,
})),
...storicoMatch,
];
return (
<>
<PageHeader titolo="Campionato" sottotitolo="Girone C · CSI Milano" />
<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" />
Dati CSI aggiornati oggi alle 08:40 (demo)
</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-[10px] 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.map((r) => {
const noi = 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>
</Section>
<Section titolo="Ultimi risultati del girone">
<div className="space-y-2">
{risultati.map((m) => (
<div
key={m.id}
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-card p-3 shadow-card"
>
<p className="truncate text-sm">
{m.casa ? "CRAP Volley" : m.avversario} {m.casa ? m.avversario : "CRAP Volley"}
</p>
<span className="font-display text-lg tabular-nums">
{m.casa ? `${m.setNostri}-${m.setLoro}` : `${m.setLoro}-${m.setNostri}`}
</span>
</div>
))}
</div>
</Section>
</>
);
}
+343
View File
@@ -0,0 +1,343 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData, giocatori, isAdmin } from "@/lib/crapp-data";
import {
categoriaEvento,
daCategoria,
eventoVuoto,
useEliminaEvento,
useEventi,
useSalvaEvento,
type CategoriaEvento,
type Evento,
} from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
export const Route = createFileRoute("/eventi")({
head: () => ({
meta: [
{ title: "Gestione eventi — CrAPP" },
{
name: "description",
content:
"Area riservata ai referenti CRAP Volley: crea, modifica ed elimina allenamenti, partite ed eventi di squadra.",
},
{ property: "og:title", content: "Gestione eventi — CrAPP" },
{
property: "og:description",
content: "Crea e modifica il calendario della squadra e scegli i convocati.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
],
}),
component: GestioneEventi,
});
const tipi: Array<{ id: CategoriaEvento; label: string }> = [
{ id: "allenamento", label: "Allenamento" },
{ id: "partita", label: "Partita" },
{ id: "amichevole", label: "Amichevole" },
{ id: "evento", label: "Evento" },
];
function GestioneEventi() {
const io = useGiocatoreCorrente();
const { eventi, isPending } = useEventi();
const salva = useSalvaEvento();
const elimina = useEliminaEvento();
const [bozza, setBozza] = useState<Evento | null>(null);
if (!io || !isAdmin(io.id)) {
return (
<>
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
<div className="px-5">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Solo i referenti della squadra possono creare o modificare gli eventi.
</p>
</div>
</>
);
}
function aggiorna(patch: Partial<Evento>) {
setBozza((b) => (b ? { ...b, ...patch } : b));
}
async function conferma() {
if (!bozza) return;
if (!bozza.titolo.trim()) {
toast.error("Serve un titolo per l'evento");
return;
}
try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
toast.success("Evento salvato");
setBozza(null);
} catch {
toast.error("Non sono riuscito a salvare l'evento");
}
}
async function rimuovi(id: string) {
try {
await elimina.mutateAsync(id);
toast.success("Evento eliminato");
if (bozza?.id === id) setBozza(null);
} catch {
toast.error("Non sono riuscito a eliminare l'evento");
}
}
return (
<>
<div className="px-5 pt-4">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Calendario
</Link>
</div>
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
<div className="px-5 pt-4">
<button
type="button"
onClick={() => setBozza(eventoVuoto())}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Nuovo evento
</button>
</div>
{bozza ? (
<Section titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}>
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="grid grid-cols-4 gap-1 rounded-full bg-secondary p-1">
{tipi.map((t) => (
<button
key={t.id}
type="button"
onClick={() => aggiorna(daCategoria(t.id))}
className={cn(
"rounded-full py-2 text-[11px] font-bold uppercase transition-colors",
categoriaEvento(bozza) === t.id
? "bg-card shadow-card text-foreground"
: "text-muted-foreground",
)}
>
{t.label}
</button>
))}
</div>
<Campo label="Titolo">
<input
value={bozza.titolo}
maxLength={80}
onChange={(e) => aggiorna({ titolo: e.target.value })}
placeholder="Es. CRAP Volley vs Aurora Nera"
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data">
<input
type="date"
value={bozza.data}
onChange={(e) => aggiorna({ data: e.target.value })}
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
</Campo>
<Campo label="Ora">
<input
type="time"
value={bozza.ora}
onChange={(e) => aggiorna({ ora: e.target.value })}
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
</Campo>
</div>
<Campo label="Luogo">
<input
value={bozza.luogo}
maxLength={80}
onChange={(e) => aggiorna({ luogo: e.target.value })}
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
</Campo>
<Campo label="Note">
<textarea
value={bozza.note}
maxLength={300}
rows={2}
onChange={(e) => aggiorna({ note: e.target.value })}
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
</Campo>
{bozza.tipo === "partita" ? (
<>
<Campo label="Dove si gioca">
<div className="flex rounded-full bg-secondary p-1">
{[
{ casa: true, label: "In casa" },
{ casa: false, label: "Fuori casa" },
].map((o) => (
<button
key={o.label}
type="button"
onClick={() => aggiorna({ casa: o.casa })}
className={cn(
"flex-1 rounded-full py-2 text-xs font-bold uppercase transition-colors",
bozza.casa === o.casa
? "bg-card shadow-card text-foreground"
: "text-muted-foreground",
)}
>
{o.label}
</button>
))}
</div>
</Campo>
<div className="flex flex-wrap gap-2">
<Interruttore
attivo={bozza.pagelleChiuse}
onClick={() => aggiorna({ pagelleChiuse: !bozza.pagelleChiuse })}
label="Pagelle chiuse"
/>
</div>
</>
) : null}
<Campo
label={`Convocati (${bozza.convocati.length === 0 ? "tutta la rosa" : bozza.convocati.length})`}
>
<div className="grid grid-cols-2 gap-1.5">
{giocatori.map((g) => {
const scelto = bozza.convocati.includes(g.id);
return (
<button
key={g.id}
type="button"
onClick={() =>
aggiorna({
convocati: scelto
? bozza.convocati.filter((x) => x !== g.id)
: [...bozza.convocati, g.id],
})
}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
scelto ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
)}
>
{g.nome}
</button>
);
})}
</div>
</Campo>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => setBozza(null)}
className="flex-1 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
Annulla
</button>
<button
type="button"
onClick={conferma}
disabled={salva.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
</button>
</div>
</div>
</Section>
) : null}
<Section titolo="Eventi in calendario">
{isPending ? (
<p className="text-center text-xs text-muted-foreground">Carico gli eventi</p>
) : (
<div className="space-y-2">
{eventi.map((e) => (
<div key={e.id} className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-[11px] text-muted-foreground">
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
<button
type="button"
onClick={() => setBozza(e)}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Modifica ${e.titolo}`}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => rimuovi(e.id)}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
aria-label={`Elimina ${e.titolo}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</Section>
</>
);
}
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="mt-1 block">{children}</span>
</label>
);
}
function Interruttore({
attivo,
onClick,
label,
}: {
attivo: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1.5 text-[11px] font-bold uppercase transition-colors",
attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
)}
>
{label}
</button>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Flame, ChevronRight } from "lucide-react";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
import { classifica, storicoMatch } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { useIo, useObiettivi } from "@/lib/rosa";
export const Route = createFileRoute("/")({
head: () => ({
meta: [
{ title: "CrAPP — L'app del CRAP Volley" },
{
name: "description",
content:
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
},
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
{
property: "og:description",
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
},
],
}),
component: Index,
});
function Index() {
const giocatore = useIo();
const { eventi } = useEventi();
const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null;
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const noi = classifica.find((r) => r.squadra === "CRAP Volley")!;
const ultima = storicoMatch[0]!;
const obiettivi = useObiettivi();
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
if (!giocatore) return null;
return (
<>
<Reveal as="section" className="bg-hero px-5 pb-10 pt-7 text-primary-foreground">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/60">
Ciao {giocatore.nome.split(" ")[0]}
</p>
<h1 className="font-display text-4xl uppercase leading-none">CrAPP</h1>
</div>
<TeamLogo className="h-12 w-12" />
</div>
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">{noi.pos}º</p>
<p className="text-[10px] uppercase text-primary-foreground/60">In classifica</p>
</div>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">
{noi.vinte}-{noi.perse}
</p>
<p className="text-[10px] uppercase text-primary-foreground/60">Bilancio</p>
</div>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-[10px] uppercase text-primary-foreground/60">Streak</p>
</div>
</div>
</Reveal>
<PromemoriaPalloni />
<Section
titolo="Prossimo impegno"
indice={1}
azione={
<Link
to="/calendario"
className="inline-flex items-center text-xs font-semibold text-accent"
>
Calendario <ChevronRight className="h-4 w-4" />
</Link>
}
>
{prossimo ? (
<EventoCard evento={prossimo} {...(linkProssimo ? { linkTo: linkProssimo } : {})} />
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nessun impegno in programma.
</p>
)}
<div className="mt-3">
<ScoutEntry />
</div>
</Section>
<Section titolo="Da confermare" indice={2}>
<div className="space-y-3">
{prossimi.slice(1).map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})}
</div>
</Section>
<Section
titolo="Ultima partita"
indice={3}
azione={
<Link
to="/squadra"
className="inline-flex items-center text-xs font-semibold text-accent"
>
Storico <ChevronRight className="h-4 w-4" />
</Link>
}
>
<div className="premi rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
<p className="text-xs text-muted-foreground">
{ultima.casa ? "In casa" : "Trasferta"} · MVP {ultima.mvp}
</p>
</div>
<p className="font-display text-3xl leading-none text-accent">
{ultima.setNostri}-{ultima.setLoro}
</p>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{ultima.parziali.map((p, i) => (
<span
key={i}
className="rounded-lg bg-secondary px-2 py-1 text-[11px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
))}
</div>
</div>
</Section>
<Section
titolo="Obiettivo di squadra"
indice={4}
azione={
<Link to="/squadra" className="inline-flex items-center text-xs font-semibold text-accent">
Tutti <ChevronRight className="h-3 w-3" />
</Link>
}
>
{obiettivo ? (
<div className="premi rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-center gap-2 text-sm font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> {" "}
{obiettivo.valore}/{obiettivo.target}{" "}
{obiettivo.unita}.
</p>
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(obiettivo)}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{obiettivo.impatto}</p>
</div>
) : null}
</Section>
<Section titolo="Colpo d'occhio" indice={5}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" hint="+2 questo mese" />
<StatTile valore={giocatore.mediaVoto || "—"} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
</>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { storicoMatch, formatData, giocatori, isAdmin } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi";
import { Pagelle } from "@/components/crapp/Pagelle";
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
import { VotoSocial } from "@/components/crapp/VotoSocial";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
import { usePresenzeEvento } from "@/lib/presenze";
export const Route = createFileRoute("/partita/$id")({
head: () => {
const titolo = "Dettaglio partita";
return {
meta: [
{ title: `${titolo} — CrAPP` },
{ name: "description", content: "Dettaglio partita, formazione e risultati del CRAP Volley." },
{ property: "og:title", content: `${titolo} — CrAPP` },
{ property: "og:description", content: "Dettaglio partita, formazione e risultati del CRAP Volley." },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
],
};
},
component: PartitaDetail,
});
function PartitaDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const io = useGiocatoreCorrente();
const scoutMatches = useScoutMatches();
const { risposte } = usePresenzeEvento(id);
const presentiVeri = giocatori.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
).length;
if (!evento) {
return (
<div className="px-5 pt-8">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Torna al calendario
</Link>
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
</div>
);
}
const convocati = convocatiEvento(evento);
const scout = scoutMatches.find((m) => m.id === evento.id || m.data === evento.data) ?? null;
const match = scout
? {
id: scout.id,
data: scout.data,
avversario: scout.avversario,
casa: scout.casa,
setNostri: scout.setNostri,
setLoro: scout.setLoro,
parziali: scout.parziali,
}
: storicoMatch.find((m) => m.data === evento.data);
const totaliTeam = scout ? totaliSquadra([scout]) : null;
const avversario = evento.titolo.includes(" vs ")
? evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo
: evento.titolo;
const casa = evento.casa;
const vinta = match && match.setNostri > match.setLoro;
return (
<>
<div className="px-5 pt-4">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Calendario
</Link>
</div>
<PageHeader
titolo={evento.campionato ? "Partita" : "Amichevole"}
sottotitolo={formatData(evento.data)}
/>
<Section titolo={evento.titolo}>
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center gap-3">
<div
className={cn(
"grid h-12 w-12 shrink-0 place-items-center rounded-2xl",
casa ? "bg-primary/15 text-primary" : "bg-accent/15 text-accent",
)}
>
{casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{casa ? "In casa" : "Fuori casa"}
{evento.campionato ? " · Campionato" : " · Amichevole"}
</p>
<p className="truncate text-lg font-bold leading-tight">{avversario}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
</div>
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocati.length}
</div>
<TurnoPalloni eventoId={evento.id} />
</div>
</Section>
{match ? (
<Section titolo="Risultato">
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">CRAP Volley</span>
<span
className={cn(
"rounded-full px-3 py-1 text-lg font-display font-bold",
vinta ? "bg-success text-success-foreground" : "bg-destructive text-destructive-foreground",
)}
>
{match.setNostri} - {match.setLoro}
</span>
<span className="text-right text-sm font-bold">{avversario}</span>
</div>
<div className="mt-4 space-y-2">
{match.parziali.map(([noi, loro], i) => (
<div key={i} className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2">
<span className="font-display text-lg">{noi}</span>
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
<span className="font-display text-lg">{loro}</span>
</div>
))}
</div>
<p className="mt-3 text-center text-xs font-semibold text-muted-foreground">
MVP eletto dalla squadra
</p>
<VotazioneMvp matchId={match.id} />
</div>
</Section>
) : null}
{match ? (
<Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />
</Section>
) : (
<Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il risultato.
</p>
</Section>
)}
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} />
</Section>
{match ? (
<Section titolo="Pagelle di fine partita">
<Pagelle matchId={match.id} convocati={convocati} chiuse={evento.pagelleChiuse} />
</Section>
) : null}
{scout && totaliTeam ? (
<Section titolo="Report tecnico">
<div className="rounded-3xl bg-card p-4 shadow-card">
<div className="grid grid-cols-4 gap-2 text-center">
{[
{ l: "Punti", v: totaliTeam.punti },
{ l: "Ace", v: totaliTeam.ace },
{ l: "Muri", v: totaliTeam.muri },
{ l: "Errori", v: totaliTeam.errori },
].map((t) => (
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
<p className="font-display text-xl leading-none">{t.v}</p>
<p className="mt-1 text-[10px] font-semibold uppercase text-muted-foreground">
{t.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
Dettaglio giocatori (uso interno allenatori)
</p>
<div className="mt-2 space-y-1">
{[...totaliPerGiocatore(scout.azioni).entries()].map(([gid, t]) => {
const g = giocatori.find((x) => x.id === gid);
if (!g) return null;
return (
<div
key={gid}
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-xs"
>
<span className="min-w-0 flex-1 truncate font-semibold">
#{g.numero} {g.nome}
</span>
<span className="tabular-nums text-muted-foreground">
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
</span>
</div>
);
})}
</div>
{io && isAdmin(io.id) ? (
<button
type="button"
onClick={() => scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout))}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV
</button>
) : null}
</div>
</Section>
) : null}
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} />
</Section>
</>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Flame, Camera, Users, Trash2, Bell } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import { fileToAvatar, salvaAvatar, rimuoviAvatar, useAvatar } from "@/lib/avatar-store";
import { SerieGriglia } from "@/components/crapp/SerieCard";
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
import { useVotiSocial } from "@/lib/badge-social";
import { useIo } from "@/lib/rosa";
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
import {
attivaNotifiche,
disattivaNotifiche,
pushSupportato,
statoNotifiche,
} from "@/lib/push-client";
import { resetGiocatore } from "@/lib/user-store";
import { Reveal } from "@/components/motion/Reveal";
export const Route = createFileRoute("/profilo")({
head: () => ({
meta: [
{ title: "Profilo giocatore — CrAPP" },
{
name: "description",
content: "Foto, ruolo, statistiche personali, badge e obiettivi del giocatore CRAP Volley.",
},
{ property: "og:title", content: "Profilo giocatore — CrAPP" },
{ property: "og:description", content: "Statistiche personali, badge e presenze della stagione." },
],
}),
component: Profilo,
});
function Profilo() {
const votiSocial = useVotiSocial();
const g = useIo();
const ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null);
const foto = useAvatar(g?.id);
const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true);
useEffect(() => {
setSupportate(pushSupportato());
statoNotifiche().then(setNotifiche).catch(() => setNotifiche(false));
}, []);
if (!g) return null;
async function cambiaNotifiche() {
if (!g || inCorso) return;
setInCorso(true);
try {
if (notifiche) {
await disattivaNotifiche();
setNotifiche(false);
toast.success("Notifiche disattivate");
} else {
await attivaNotifiche(g.id);
setNotifiche(true);
toast.success("Notifiche palloni attive");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
} finally {
setInCorso(false);
}
}
const percPresenze = Math.round((g.presenze / g.totaliEventi) * 100);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !g) return;
try {
salvaAvatar(g.id, await fileToAvatar(file));
toast.success("Immagine profilo aggiornata");
} catch {
toast.error("Non sono riuscito a caricare l'immagine");
}
}
return (
<>
<PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} />
<Reveal className="-mt-6 px-5">
<div className="premi rounded-3xl bg-card p-4 shadow-card">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" />
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFile}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="absolute -bottom-1 -right-1 grid h-8 w-8 place-items-center rounded-full bg-accent-grad text-accent-foreground shadow-pop"
aria-label="Cambia foto"
>
<Camera className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex-1 rounded-xl bg-secondary px-3 py-2 text-xs font-bold uppercase tracking-wide"
>
Cambia immagine profilo
</button>
{foto ? (
<button
type="button"
onClick={() => {
rimuoviAvatar(g.id);
toast.success("Immagine rimossa");
}}
className="grid h-9 w-9 place-items-center rounded-xl bg-secondary text-muted-foreground"
aria-label="Rimuovi immagine"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</div>
</Reveal>
<Section titolo="Stagione" indice={1}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={g.presenze} label="Presenze" hint={`${percPresenze}% del totale`} />
<StatTile
valore={`${ultimoMese.percentuale}%`}
label="Presenze 30gg"
hint={`${ultimoMese.presenti}/${ultimoMese.totali} eventi`}
/>
<StatTile
valore={
<span className="inline-flex items-center gap-1">
<Flame className="h-5 w-5 text-accent" />
{g.streak}
</span>
}
label="Presenze di fila"
/>
<StatTile valore={g.mediaVoto || "—"} label="Media voto" />
<StatTile valore={g.mvp} label="MVP" />
<StatTile valore={g.palloni} label="Turni palloni" />
</div>
</Section>
<Section titolo="Serie di presenze" indice={2}>
<SerieGriglia g={g} />
</Section>
<Section titolo="Collezione badge" indice={3}>
<CollezioneBadge g={g} votiSocial={votiSocial.data ?? []} />
</Section>
<Section titolo="Impostazioni">
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={cambiaNotifiche}
disabled={!supportate || inCorso}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche turno palloni</span>
<span className="block text-[11px] text-muted-foreground">
{supportate
? notifiche
? "Attive su questo dispositivo"
: "Ricevi l'avviso il giorno stesso e la volta dopo"
: "Non supportate su questo dispositivo"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
notifiche ? "bg-accent-grad text-accent-foreground" : "bg-secondary text-muted-foreground",
)}
>
<Bell className="h-4 w-4" />
</span>
</button>
{["Notifiche convocazioni", "Promemoria allenamenti", "Cambi orario", "Bacheca squadra"].map(
(v) => (
<label key={v} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<span className="min-w-0 truncate">{v}</span>
<input type="checkbox" defaultChecked className="h-5 w-9 accent-[var(--accent)]" />
</label>
),
)}
<button
type="button"
onClick={() => resetGiocatore()}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Cambia giocatore</span>
<Users className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</Section>
</>
);
}
+506
View File
@@ -0,0 +1,506 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { giocatori, formatData, isAdmin } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import {
statoIniziale,
useCancellaStatoScout,
useSalvaStatoScout,
useStatoScout,
type StatoScout,
} from "@/lib/scout-stato";
import {
SCADENZA_MINUTI,
sessioneScaduta,
useApriSessioneScout,
useChiudiSessioneScout,
useHeartbeatScout,
usePartitaDiOggi,
useSessioneScout,
} from "@/lib/scout-live";
import {
azioniMeta,
salvaScoutMatch,
totaliPerGiocatore,
type Azione,
type AzioneTipo,
} from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({
head: () => ({
meta: [
{ title: "Scout live partita — CrAPP" },
{
name: "description",
content:
"Registra punti, ace, muri ed errori del CRAP Volley in tempo reale durante la partita.",
},
{ property: "og:title", content: "Scout live partita — CrAPP" },
{
property: "og:description",
content: "Un tap per giocatore e azione: le stats finiscono subito nel database squadra.",
},
],
}),
component: Scout,
});
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({ icona, titolo, testo, children }: { icona: React.ReactNode; titolo: string; testo: string; children?: React.ReactNode }) {
return (
<div className="px-5 py-10">
<div className="rounded-3xl bg-card p-6 text-center shadow-card">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-secondary text-accent">
{icona}
</div>
<h1 className="mt-4 font-display text-2xl uppercase leading-none">{titolo}</h1>
<p className="mt-2 text-sm text-muted-foreground">{testo}</p>
{children}
</div>
</div>
);
}
function Scout() {
const { pronto, partita } = usePartitaDiOggi();
const io = useGiocatoreCorrente();
const sessione = useSessioneScout(partita?.id ?? null);
const statoSalvato = useStatoScout(partita?.id ?? null);
const apri = useApriSessioneScout();
const chiudi = useChiudiSessioneScout();
const [controllo, setControllo] = useState(false);
useHeartbeatScout(partita?.id ?? null, io?.id ?? null, controllo);
useEffect(() => {
if (!controllo || !partita || !io) return;
const rilascia = () => {
void chiudi.mutateAsync({ eventoId: partita.id, giocatoreId: io.id });
};
window.addEventListener("pagehide", rilascia);
return () => window.removeEventListener("pagehide", rilascia);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controllo, partita?.id, io?.id]);
if (io && !isAdmin(io.id)) {
return (
<Blocco
icona={<Lock className="h-6 w-6" />}
titolo="Scout riservato"
testo="Lo scout live è uno strumento tecnico per allenatori e referenti della squadra."
/>
);
}
if (!pronto || sessione.isLoading || statoSalvato.isLoading) {
return <Blocco icona={<Radio className="h-6 w-6" />} titolo="Scout live" testo="Caricamento…" />;
}
if (!partita) {
return (
<Blocco
icona={<CalendarX2 className="h-6 w-6" />}
titolo="Nessuna partita oggi"
testo="Lo scout live si attiva automaticamente il giorno della partita."
/>
);
}
const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null;
const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id;
const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0;
if (!controllo) {
return (
<Blocco
icona={occupataDaAltri ? <Lock className="h-6 w-6" /> : <Radio className="h-6 w-6" />}
titolo={occupataDaAltri ? "Scout occupato" : "Scout live disponibile"}
testo={
occupataDaAltri
? `${attiva!.giocatore_nome} sta già scoutando questa partita. Si libera dopo ${SCADENZA_MINUTI} minuti di inattività.`
: ripresa
? `${partita.titolo} · scout già avviato: riprendi da dove è stato lasciato.`
: `${partita.titolo} · ${formatData(partita.data)} ore ${partita.ora}`
}
>
{occupataDaAltri ? (
<button
type="button"
onClick={() => sessione.refetch()}
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
Aggiorna
</button>
) : (
<button
type="button"
disabled={apri.isPending || !io}
onClick={async () => {
if (!io) return;
const ok = await apri.mutateAsync({
eventoId: partita.id,
giocatoreId: io.id,
nome: io.nome,
});
if (ok) setControllo(true);
else toast.error("Un altro compagno ha appena preso lo scout");
}}
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
</button>
)}
</Blocco>
);
}
return (
<ScoutBoard
partita={partita}
iniziale={statoSalvato.data ?? null}
onFine={() => {
setControllo(false);
if (io) void chiudi.mutateAsync({ eventoId: partita.id, giocatoreId: io.id });
}}
/>
);
}
function ScoutBoard({
partita,
iniziale,
onFine,
}: {
partita: Evento;
iniziale: StatoScout | null;
onFine: () => void;
}) {
const navigate = useNavigate();
const base =
iniziale ??
statoIniziale(
partita.titolo.replace(/CRAP Volley/gi, "").replace(/\s*vs\s*/i, "").trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"),
);
const [avversario, setAvversario] = useState(base.avversario);
const [casa, setCasa] = useState(base.casa);
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
const [selezionato, setSelezionato] = useState<string | null>(null);
const salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout();
const { risposte } = usePresenzeEvento(partita.id);
const finito = useRef(false);
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
const convocati = useMemo(() => {
const presenti = giocatori.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
);
return presenti.length > 0 ? presenti : giocatori;
}, [risposte]);
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
useEffect(() => {
if (finito.current) return;
const id = window.setTimeout(() => {
void salva.mutateAsync({
eventoId: partita.id,
stato: { azioni, setChiusi, avversario, casa },
});
}, 800);
return () => window.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [azioni, setChiusi, avversario, casa, partita.id]);
const setCorrente = setChiusi.length + 1;
const azioniSet = azioni.filter((a) => a.set === setCorrente);
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
const puntiLoro = azioniSet.length - puntiNoi;
const setNostri = setChiusi.filter(([n, l]) => n > l).length;
const setLoro = setChiusi.length - setNostri;
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
function registra(tipo: AzioneTipo) {
const meta = azioniMeta[tipo];
if (meta.richiedeGiocatore && !selezionato) {
toast.error("Seleziona prima il giocatore");
return;
}
setAzioni((prev) => {
const nuova: Azione = {
id: `${Date.now()}-${prev.length}`,
tipo,
set: setCorrente,
ts: Date.now(),
...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}),
};
return [...prev, nuova];
});
}
function annulla() {
const ultima = azioni[azioni.length - 1];
if (!ultima || ultima.set !== setCorrente) {
toast.error("Puoi annullare solo le azioni del set in corso");
return;
}
setAzioni((prev) => prev.slice(0, -1));
}
function chiudiSet() {
if (puntiNoi === 0 && puntiLoro === 0) {
toast.error("Set senza azioni");
return;
}
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
}
function finePartita() {
const parziali: Array<[number, number]> =
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
if (parziali.length === 0) {
toast.error("Nessun set da salvare");
return;
}
const vinti = parziali.filter(([n, l]) => n > l).length;
salvaScoutMatch({
id: `s${Date.now()}`,
data: new Date().toISOString().slice(0, 10),
avversario: avversario.trim() || "Avversario",
casa,
setNostri: vinti,
setLoro: parziali.length - vinti,
parziali,
mvp: "",
azioni,
});
toast.success("Partita salvata: ora la squadra può votare l'MVP");
finito.current = true;
void cancella.mutateAsync(partita.id);
onFine();
navigate({ to: "/squadra" });
}
const ultime = [...azioni].slice(-4).reverse();
return (
<>
<header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide">
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={annulla}
disabled={azioniSet.length === 0}
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40"
>
<Undo2 className="h-3.5 w-3.5" /> Annulla
</button>
<button
type="button"
onClick={onFine}
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold"
>
<LogOut className="h-3.5 w-3.5" /> Rilascia
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<div className="min-w-0">
<p className="truncate text-[11px] uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
</div>
<p className="font-display text-2xl leading-none text-primary-foreground/50">
{setNostri}-{setLoro}
</p>
<div className="min-w-0">
<p className="truncate text-[11px] uppercase text-primary-foreground/60">
{avversario || "Avversario"}
</p>
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
</div>
</div>
{setChiusi.length > 0 ? (
<div className="mt-2 flex flex-wrap justify-center gap-1.5">
{setChiusi.map((p, i) => (
<span
key={i}
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-[11px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
))}
</div>
) : null}
</header>
<section className="px-5 pt-4">
<p className="mb-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
1. Tocca il giocatore
</p>
<div className="grid grid-cols-4 gap-2">
{convocati.map((g) => {
const attivo = selezionato === g.id;
const t = totali.get(g.id);
return (
<button
key={g.id}
type="button"
onClick={() => setSelezionato(attivo ? null : g.id)}
className={cn(
"rounded-2xl p-2 text-center transition-all active:scale-95",
attivo ? "bg-accent text-accent-foreground shadow-pop" : "bg-card shadow-card",
)}
>
<p className="font-display text-2xl leading-none">{g.numero}</p>
<p className="mt-1 text-[10px] font-bold leading-tight">{g.nome}</p>
<p
className={cn(
"text-[10px] tabular-nums",
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
)}
>
{t ? `${t.punti}p` : "0p"}
</p>
</button>
);
})}
</div>
</section>
<section className="px-5 pt-4">
<p className="mb-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
2. Tocca l'azione
</p>
<div className="grid grid-cols-2 gap-2">
{ordineAzioni.map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
!selezionato && "opacity-50",
)}
>
{azioniMeta[tipo].label}
</button>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
)}
>
{azioniMeta[tipo].short}
</button>
))}
</div>
</section>
<section className="px-5 pt-4">
<div className="rounded-3xl bg-card p-3 shadow-card">
<p className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
Ultime azioni
</p>
{ultime.length === 0 ? (
<p className="mt-2 text-xs text-muted-foreground">Nessuna azione registrata.</p>
) : (
<ul className="mt-2 space-y-1.5">
{ultime.map((a, i) => {
const g = giocatori.find((x) => x.id === a.giocatoreId);
return (
<li
key={a.id}
className={cn(
"flex items-center justify-between gap-2 px-1 text-xs",
i === 0 && "anim-riga",
)}
>
<span className="truncate">
{g ? `#${g.numero} ${g.nome}` : "Avversario"}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase",
azioniMeta[a.tipo].className,
)}
>
{azioniMeta[a.tipo].short}
</span>
</li>
);
})}
</ul>
)}
</div>
</section>
<section className="px-5 pt-4">
<div className="rounded-3xl bg-card p-4 shadow-card">
<label className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
Avversario
</label>
<input
value={avversario}
maxLength={40}
onChange={(e) => setAvversario(e.target.value)}
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
<div className="mt-3 flex rounded-full bg-secondary p-1">
{[true, false].map((v) => (
<button
key={String(v)}
type="button"
onClick={() => setCasa(v)}
className={cn(
"flex-1 rounded-full py-1.5 text-xs font-bold uppercase",
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
)}
>
{v ? "In casa" : "Trasferta"}
</button>
))}
</div>
</div>
</section>
<section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
<button
type="button"
onClick={chiudiSet}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
<CheckCircle2 className="h-4 w-4" /> Chiudi set
</button>
<button
type="button"
onClick={finePartita}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Save className="h-4 w-4" /> Fine partita
</button>
</section>
</>
);
}
+440
View File
@@ -0,0 +1,440 @@
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 } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData, storicoMatch } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useRosa, useObiettivi } from "@/lib/rosa";
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
import { totaliSquadra } from "@/lib/scout-store";
import { mediaSquadra, usePagelle } from "@/lib/pagelle";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { StatTile } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
import { useScoutMatches } from "@/lib/scout-store";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { BadgeDrawer } from "@/components/crapp/BadgeDrawer";
import {
badgeDefs,
badgeGiocatore,
badgeSegretiSbloccati,
descrizioneSoglie,
gradiOrdine,
gradoMeta,
gradoRaggiunto,
collezioneBadge,
} from "@/lib/badges";
function RuoloBadge({ ruolo }: { ruolo: string }) {
return (
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent-foreground">
{ruolo}
</span>
);
}
export const Route = createFileRoute("/squadra")({
head: () => ({
meta: [
{ title: "Squadra CRAP Volley — CrAPP" },
{
name: "description",
content: "Rosa completa del CRAP Volley: giocatori, dati anagrafici, statistiche stagionali e badge sbloccati.",
},
{ property: "og:title", content: "Squadra CRAP Volley — CrAPP" },
{
property: "og:description",
content: "Tutti i giocatori della rosa con dati, statistiche e obiettivi raggiunti.",
},
],
}),
component: Squadra,
});
const criteri = [
{ id: "presenze", label: "Presenze" },
{ id: "mediaVoto", label: "Media voto" },
{ id: "mvp", label: "MVP" },
{ id: "palloni", label: "Palloni" },
{ id: "cacchePartita", label: "Cacche/partita" },
] as const;
type Criterio = (typeof criteri)[number]["id"];
function valore(
g: { presenze: number; mediaVoto: number; mvp: number; palloni: number; cacchePartita: number },
c: Criterio,
) {
return g[c] ?? 0;
}
function Squadra() {
const rosa = useRosa();
const mese = usePresenzeUltimoMeseTutti();
const scoutMatches = useScoutMatches();
const votiMvp = useVotiMvp();
const { voti: pagelle } = usePagelle();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const [aperto, setAperto] = useState<string | null>(null);
const [criterio, setCriterio] = useState<Criterio>("presenze");
const obiettivi = useObiettivi();
const team = totaliSquadra(scoutMatches);
const mediaPresenze = rosa.length
? Math.round((rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100)
: 0;
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
const tuttiMatch = [
...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: mvpPerMatch[m.id] ?? "",
scout: true,
})),
...storicoMatch.map((m) => ({ ...m, scout: false })),
];
const completati = obiettivi.filter((o) => progressoObiettivo(o) >= 100).length;
const mediaObiettivi = obiettivi.length
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
: 0;
return (
<>
<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 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-[11px] 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} className="cursor-help" title={b.def.nome}>
<Icon className={cn("h-3 w-3", gradoMeta[b.grado!].text)} />
</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-[10px] font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-[11px] 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-[10px] 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-[10px] font-bold uppercase", meta.text)}>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</div>
) : null}
</article>
);
})}
</div>
</Section>
<Section titolo="Statistiche di squadra">
<div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={storicoMatch.length + scoutMatches.length} 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>
<div className="mt-3">
<ScoutEntry />
</div>
</Section>
<Section titolo="Classifica giocatori">
<div className="-mx-5 mb-3 flex gap-2 overflow-x-auto px-5 pb-1">
{criteri.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCriterio(c.id)}
className={cn(
"shrink-0 rounded-full px-3 py-1.5 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-[11px] 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>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad"
style={{ width: `${Math.max(6, (valore(g, criterio) / max) * 100)}%` }}
/>
</div>
</div>
))}
</div>
</Section>
<Section titolo="Storico match">
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
return (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
<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-[11px] 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 gap-1.5">
{m.parziali.map((p, i) => (
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-[11px] font-semibold tabular-nums",
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
{p[0]}-{p[1]}
</span>
))}
</div>
</article>
);
})}
</div>
</Section>
<Section titolo="Obiettivi di squadra">
<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-[11px] 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-[10px] 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-[11px] text-muted-foreground">{o.impatto}</p>
</Reveal>
);
})}
</div>
</Section>
<Section 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-[10px] 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-[10px] font-bold uppercase", meta.text)}>
{meta.label}
</p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
<p className="text-[10px] text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
);
})}
</div>
</div>
</BadgeDrawer>
);
})}
</div>
</Section>
</>
);
}