Implementa la voce "Dashboard amministratore" della roadmap v1.1, come descritta in docs/modules/profilo-giocatore.md, insieme alla parte di profilo che la alimenta. - /admin: stato dei profili della squadra, download di documento, certificato e foto tessera, export CSV con i 12 campi del tesseramento CSI. Un certificato scaduto non conta come valido. - Profilo giocatore: dati personali, documento (fronte e retro), certificato e foto tessera, con widget di completamento in Home che sparisce al 100%. - I permessi di amministrazione arrivano da user_roles (DD-011) e non più dalla lista di nomi in crapp-data.ts, che resta come ponte finché VITE_AUTH_OBBLIGATORIA non viene acceso in produzione. - Login Google via Supabase Auth: al primo accesso l'account si collega a uno slot libero di giocatori_squadra, e il vincolo lo fa rispettare il trigger di M1 (DD-016 regola 2). Migration additive: M2 crea profili_giocatore, M3 il bucket privato profili-giocatore. Nessuna tabella v1.0 viene toccata, quindi si possono applicare senza cambiare il comportamento attuale dell'app. supabase/config.toml e seed.sql configurano lo stack locale: serve perché il progetto Supabase è uno solo, condiviso tra sviluppo e produzione. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
9.2 KiB
TypeScript
253 lines
9.2 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Flame, Camera, Users, Trash2, Bell, LogOut, ShieldCheck } 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 { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
|
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 { esci, useSessione } from "@/lib/auth";
|
|
import { useIsAdmin } from "@/lib/ruoli";
|
|
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 admin = useIsAdmin();
|
|
const { sessione } = useSessione();
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await esci();
|
|
resetGiocatore();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
<ProfiloAmministrativo giocatoreId={g.id} indice={4} />
|
|
|
|
<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>
|
|
),
|
|
)}
|
|
{admin ? (
|
|
<Link
|
|
to="/admin"
|
|
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">Dashboard amministratore</span>
|
|
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
|
</Link>
|
|
) : null}
|
|
<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>
|
|
{sessione ? (
|
|
<button
|
|
type="button"
|
|
onClick={logout}
|
|
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">Esci</span>
|
|
<LogOut className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</Section>
|
|
</>
|
|
);
|
|
} |