Initial independent version of CrAPP
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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.",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user