Files
CRAPP/src/routes/profilo.tsx
T
davideandClaude Sonnet 5 d2d62b6799 Sincronizza le foto profilo dei giocatori su Supabase Storage
Le foto caricate in Profilo vivevano solo in localStorage: ogni giocatore
vedeva la propria foto solo sul proprio dispositivo, mai quella dei
compagni nella rosa (Squadra). Ora vengono caricate in un bucket
pubblico dedicato (avatar-giocatori, migration m6) e il componente
Avatar carica l'URL pubblico con fallback su numero/iniziali se assente
o non ancora caricata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:32:19 +02:00

268 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, 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 {
caricaAvatar,
rimuoviAvatar,
useAvatarEsiste,
useInvalidaAvatarEsiste,
} 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 } 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 ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null);
const fotoEsiste = useAvatarEsiste(g?.id);
const invalidaAvatarEsiste = useInvalidaAvatarEsiste();
const [bust, setBust] = useState(0);
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 {
await caricaAvatar(g.id, file);
setBust(Date.now());
invalidaAvatarEsiste(g.id);
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" bust={bust} />
<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>
{fotoEsiste.data ? (
<button
type="button"
onClick={async () => {
try {
await rimuoviAvatar(g.id);
setBust(Date.now());
invalidaAvatarEsiste(g.id);
toast.success("Immagine rimossa");
} catch {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}}
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={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>
</div>
</Section>
</>
);
}