Files
CRAPP/src/components/crapp/ui-bits.tsx
T

239 lines
7.1 KiB
TypeScript
Raw Normal View History

import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown } from "lucide-react";
2026-08-06 08:36:47 +02:00
import { cn } from "@/lib/utils";
import { statoMeta, type Stato } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa";
import { Avatar } from "@/components/crapp/Avatar";
2026-08-06 08:36:47 +02:00
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
export function TeamLogo({
className,
/**
* Di default l'icona della PWA, che ha dentro il nome dell'app. Dove serve lo
* stemma della squadra e basta si passa `/logo-nerorosso.svg`.
*/
src = "/icon-192.png",
}: {
className?: string;
src?: string;
}) {
2026-08-06 08:36:47 +02:00
return (
<img
src={src}
2026-08-06 08:36:47 +02:00
alt="CRAP Volley"
width={192}
height={192}
2026-08-06 08:36:47 +02:00
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
/>
);
}
/**
* Superficie standard dell'app: era ripetuta a mano una ventina di volte come
* `rounded-3xl bg-card p-4 shadow-card`, quindi cambiare raggio od ombra
* voleva dire toccare venti file.
*
* Gerarchia dei raggi: contenitore `3xl` → elemento interno `2xl` →
* controllo `full`.
*/
export function Card({
className,
as: Tag = "div",
...props
}: ComponentPropsWithoutRef<"div"> & { as?: "div" | "article" | "section" }) {
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
}
/**
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
* `PageHeader` per rimetterci il logo — sarebbe un link a sé stessa.
*/
export function LinkProfilo() {
const g = useIo();
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={g.iniziali} className="h-12 w-12 text-lg" />
</Link>
);
}
export function PageHeader({
titolo,
sottotitolo,
azione,
}: {
titolo: string;
sottotitolo?: string;
/** Sostituisce il link al profilo in alto a destra. */
azione?: ReactNode;
}) {
2026-08-06 08:36:47 +02:00
return (
<header className="bg-hero px-5 pb-8 pt-7 text-primary-foreground">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
<div className="min-w-0">
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
2026-08-06 08:36:47 +02:00
{sottotitolo ? (
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
2026-08-06 08:36:47 +02:00
) : null}
</div>
{azione ?? <LinkProfilo />}
2026-08-06 08:36:47 +02:00
</div>
</header>
);
}
export function Section({
titolo,
azione,
children,
indice = 0,
}: {
titolo?: string;
2026-08-06 08:36:47 +02:00
azione?: ReactNode;
children: ReactNode;
indice?: number;
}) {
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
{titolo || azione ? (
<div className="mb-3 flex items-center justify-between gap-3">
{titolo ? <h2 className="font-display-sm text-lg uppercase">{titolo}</h2> : <span />}
{azione}
</div>
) : null}
2026-08-06 08:36:47 +02:00
{children}
</Reveal>
);
}
/** Sezione con titolo cliccabile: il contenuto si apre e chiude. `anteprima` resta sempre visibile. */
export function SezioneTendina({
titolo,
children,
anteprima,
azione,
defaultAperta = false,
indice = 0,
}: {
titolo: string;
children: ReactNode;
anteprima?: ReactNode;
azione?: ReactNode;
defaultAperta?: boolean;
indice?: number;
}) {
const [aperta, setAperta] = useState(defaultAperta);
const id = useId();
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
<button
type="button"
onClick={() => setAperta((v) => !v)}
className="mb-3 flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]"
aria-expanded={aperta}
aria-controls={id}
>
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
<span className="flex shrink-0 items-center gap-2">
{azione}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
aperta && "rotate-180",
)}
/>
</span>
</button>
{anteprima}
<div id={id}>{aperta ? children : null}</div>
</Reveal>
);
}
/**
* Classi condivise di input, select e textarea nei form dell'app. `h-10`:
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche
* con `appearance-none`, restano un filo più alti di un input di testo a
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
*/
export const classiInput =
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
/**
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
* è l'elemento di griglia e ha `min-width: auto`, quindi si allarga fino al
* contenuto invece di stare nella colonna. Con un controllo nativo largo dentro
* (una data o un'ora su iOS) la coppia sfonda la card.
*/
export function Campo({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block min-w-0">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="mt-1 block">{children}</span>
</label>
);
}
/**
* `<select>` nativo con la stessa altezza degli altri campi del form. Il
* controllo nativo di un `<select>` ignora parzialmente il padding di
* `classiInput` (soprattutto su Android), risultando più alto o più basso
* degli input accanto: `appearance-none` lo riporta a una scatola CSS
* normale, la freccia va poi ridisegnata a mano perché sparisce con lui.
*/
export function Select(props: ComponentPropsWithoutRef<"select">) {
return (
<span className="relative block">
<select {...props} className={cn(classiInput, "appearance-none pr-8", props.className)} />
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</span>
);
}
2026-08-06 08:36:47 +02:00
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
const meta = statoMeta[stato];
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
2026-08-06 08:36:47 +02:00
meta.className,
className,
)}
>
{meta.label}
2026-08-06 08:36:47 +02:00
</span>
);
}
export function StatTile({
valore,
label,
hint,
}: {
valore: ReactNode;
label: string;
hint?: string;
}) {
return (
<div className="premi rounded-2xl bg-card p-3 shadow-card">
<p className="font-display text-2xl leading-none">
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
</p>
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
2026-08-06 08:36:47 +02:00
{label}
</p>
{hint ? <p className="mt-0.5 text-xs text-accent">{hint}</p> : null}
2026-08-06 08:36:47 +02:00
</div>
);
}