Calendario: il mese si cambiava solo con due frecce da 36 px. Ora si scorre, e il punto d'arrivo si sceglie proiettando la velocità di rilascio invece che dalla posizione del dito, così un colpo secco «lancia» il mese. La griglia entra ed esce dallo stesso lato del gesto, `dragElastic` dà resistenza progressiva al bordo al posto di uno stop netto. Le celle con più tipi di evento generavano un gradiente a fette e ci mettevano sopra un'ombra bianca sul numero per tenerlo leggibile: un cerotto su un problema di contrasto. Ora fondo neutro e un puntino per tipo, con il numero sempre su superficie piena. L'etichetta accessibile dice quanti eventi ci sono, non solo che ce ne sono. Profilo: le quattro switch «Notifiche convocazioni», «Promemoria allenamenti», «Cambi orario» e «Bacheca squadra» erano `defaultChecked` e non facevano niente — promettevano una funzione che non esiste. Via anche la conferma nativa prima di *cambiare* la foto: non è un'azione distruttiva, e chiedere conferma per tutto insegna a rispondere sì senza leggere. Resta su quella che la rimuove, che è irreversibile. Home: la sezione «Da confermare» mostrava titolo e contenitore vuoto quando non c'era altro da confermare, mentre tutte le altre sezioni hanno un empty state. Squadra: i badge in riga avevano solo `title=`, che su touch non appare mai — aggiunto il testo per gli screen reader. I filtri per criterio erano alti ~26 px. Più, in tutte le schermate: la primitiva `Card` al posto delle classi ripetute, i bersagli sotto i 44 px portati in misura e il floor tipografico a 12 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
9.8 KiB
TypeScript
276 lines
9.8 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, Bug, Lightbulb } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Card, PageHeader, Section, SezioneTendina, 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 = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
|
|
|
|
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">
|
|
<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-11 w-11 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="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
|
>
|
|
Cambia immagine profilo
|
|
</button>
|
|
{fotoEsiste.data ? (
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
|
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-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-muted-foreground"
|
|
aria-label="Rimuovi immagine"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
</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>
|
|
|
|
<SezioneTendina titolo="Collezione badge" indice={3}>
|
|
<CollezioneBadge g={g} votiSocial={votiSocial.data ?? []} />
|
|
</SezioneTendina>
|
|
|
|
<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 min-h-11 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-xs 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>
|
|
{admin ? (
|
|
<Link
|
|
to="/admin"
|
|
className="flex min-h-11 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}
|
|
<a
|
|
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex min-h-11 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">Segnala un bug</span>
|
|
<Bug className="h-4 w-4 text-muted-foreground" />
|
|
</a>
|
|
<a
|
|
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex min-h-11 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">Suggerisci una nuova funzionalità</span>
|
|
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={logout}
|
|
className="flex min-h-11 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>
|
|
</>
|
|
);
|
|
}
|