Separazione: le aree riservate diventano un'app propria (apps/platforms)

Campus, Stress Index e Longevity escono dal sito e diventano
apps.insanitylab.it, con utenti, sessioni, login e database propri.
Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni
autosufficienti, cosi' che ognuna resti estraibile.

Misura che ha guidato il taglio: le tre aree condividevano col sito
SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts.
Layout, librerie e dati erano gia' disgiunti.

Lato piattaforme:
- ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali
- canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non
  elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno)
- tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti
- /api risponde 401/403, non un redirect verso una pagina di login
- tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi

Lato sito:
- ROLES tornano i tre redazionali; via le regole e il login pubblico
- il login del pannello RESPINGE un account emigrato invece di aprirgli una
  sessione: senza, landingFor lo mandava su /admin, il middleware glielo
  negava, e si formava un ciclo di redirect che sembra un guasto del sito
- l'header non mostra piu' un menu delle piattaforme: un link che esce

Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi
i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra
in Campus e viene rimbalzato da Longevity, il cliente il contrario.

Non ancora in produzione: manca il record DNS di apps.insanitylab.it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
AdrianoDev
2026-09-03 11:21:12 +00:00
parent 510d7ca4ec
commit 6301f521fd
383 changed files with 8477 additions and 350 deletions
+21
View File
@@ -0,0 +1,21 @@
---
// Il 404 dell'area riservata. ⚠️ Quello del sito non si poteva riusare: pesca i suoi testi
// dai contenuti taggati (content_blocks), che vivono nel database del sito e qui non
// esistono. Qui il testo è scritto in pagina — è una pagina d'errore, non un contenuto
// da modificare dal pannello.
import App from '../layouts/App.astro';
export const prerender = false;
---
<App title="Pagina non trovata" user={Astro.locals.user ?? null}>
<section class="nf">
<h1>Pagina non trovata</h1>
<p>L'indirizzo non corrisponde a niente di quest'area.</p>
<p><a href="/piattaforme">Torna alle piattaforme</a></p>
</section>
<style>
.nf { text-align: center; padding-block: 5rem; }
.nf h1 { font-family: var(--font-heading, inherit); font-size: 1.4rem; letter-spacing: .1em;
text-transform: uppercase; margin-bottom: 1rem; }
.nf p { color: var(--c-text-light, #666); }
</style>
</App>
@@ -0,0 +1,94 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
import { salvaDalForm } from '../../../lib/longevity/vista';
import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
/**
* Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare.
* `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una
* lista così invecchia appena il file rinomina la variabile, vedi il commento nel test),
* ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non
* supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello
* della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui.
*/
type CorpoQuestionario = {
risposte: Record<string, number>;
liberi: Record<string, string>;
consensoSanitario: string;
eta?: number;
};
/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è
* nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un
* eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare
* al chiamante, perché il tipo di ritorno non la contiene. */
function corpoDaJson(raw: unknown): CorpoQuestionario | null {
if (typeof raw !== 'object' || raw === null) return null;
const grezzo = raw as Record<string, unknown>;
const risposteIn = grezzo.risposte;
if (typeof risposteIn !== 'object' || risposteIn === null) return null;
const risposte: Record<string, number> = {};
for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
}
// Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
const liberiIn = grezzo.liberi;
const liberi: Record<string, string> = {};
if (typeof liberiIn === 'object' && liberiIn !== null) {
for (const campo of CAMPI_LIBERI) {
const v = (liberiIn as Record<string, unknown>)[campo];
if (typeof v === 'string') liberi[campo] = v;
}
}
const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : '';
const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined;
return { risposte, liberi, consensoSanitario, eta };
}
export const POST: APIRoute = async ({ request, locals }) => {
// Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
// corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato
// a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui
// prenderlo per errore.
const codice = codicePerUtenteLoggato(locals.user!.id);
if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
let raw: unknown;
try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' });
const corpo = corpoDaJson(raw);
if (!corpo) return json(400, { error: 'Risposte mancanti.' });
const { risposte, liberi, consensoSanitario, eta } = corpo;
// La data della compilazione è quella del server, non quella (manomettibile) del client.
const oggi = new Date().toISOString().slice(0, 10);
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
let esito;
try {
esito = salvaDalForm(getLongevityDb(), {
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
});
} catch (err) {
console.error('Salvataggio questionario fallito:', err);
return json(400, {
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
});
}
if (!esito.ok) return json(422, { error: esito.errore });
return json(201, { sessioneId: esito.sessioneId });
};
@@ -0,0 +1,113 @@
---
// Due forme di URL:
// /campus/<area> → indice dei capitoli dell'area
// /campus/<area>/<guida> → corpo della guida
import Campus from '../../layouts/Campus.astro';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
const [areaSlug, lessonSlug] = parts;
const area = findArea(nav, areaSlug);
if (!area) return new Response('Pagina non trovata', { status: 404 });
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
const body = lesson ? readLessonHtml(lesson.slug) : null;
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
const chapter = lesson ? chapterOf(nav, lesson) : null;
---
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
{lesson ? (
<article>
<nav class="cp-crumbs">
<a href="/campus">Biohacking</a> <span>/</span>
<a href={`/campus/${area.slug}`}>{area.title}</a>
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
</nav>
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
<h1>{lesson.titleFull}</h1>
{lesson.concetti.length > 0 && (
<ul class="cp-concetti">
{lesson.concetti.map((c) => <li>{c}</li>)}
</ul>
)}
<div class="cp-body" set:html={body} />
<nav class="cp-prevnext">
{lesson.prev
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
: <span />}
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
</nav>
</article>
) : (
<>
<nav class="cp-crumbs"><a href="/campus">Biohacking</a> <span>/</span> <span>{area.title}</span></nav>
<p class="eyebrow">Area di studio</p>
<h1>{area.title}</h1>
<p class="cp-lead">{area.sub}</p>
{area.chapters.map((ch) => (
<section class="cp-chapter" style={`--c:${ch.color}`}>
<h2>{ch.num}. {ch.title}</h2>
<p class="cp-chapter__sub">{ch.sub}</p>
<ul class="cp-list">
{ch.lessons.map((l) => (
<li>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-list__label">{l.label}</span>
<span class="cp-list__title">{l.titleShort}</span>
<span class="cp-list__rel">{l.relatore}</span>
</a>
</li>
))}
</ul>
</section>
))}
</>
)}
</Campus>
<style is:global>
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
.cp-crumbs a:hover { text-decoration: underline; }
.cp-crumbs span { margin: 0 4px; }
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); }
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
.cp-chapter { margin-bottom: 42px; }
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
.cp-list { list-style: none; margin: 0; padding: 0; }
.cp-list li { border-bottom: 1px solid #efece7; }
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
text-decoration: none; color: inherit; padding: 11px 16px; }
.cp-list a:hover { background: var(--c-bg-alt); }
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
.cp-prevnext a:hover { text-decoration: underline; }
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
@media (max-width: 640px) {
.cp-list a { grid-template-columns: 56px 1fr; }
.cp-list__rel { display: none; }
}
</style>
@@ -0,0 +1,35 @@
// Immagini delle slide del Campus. Passano dal middleware come il resto di
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
import type { APIRoute } from 'astro';
import { createReadStream, statSync } from 'node:fs';
import { extname } from 'node:path';
import { Readable } from 'node:stream';
import { resolveAsset } from '../../../lib/campus';
export const prerender = false;
const TYPES: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
};
export const GET: APIRoute = ({ params }) => {
const rel = params.path;
if (!rel) return new Response('Not found', { status: 404 });
const type = TYPES[extname(rel).toLowerCase()];
if (!type) return new Response('Tipo non consentito', { status: 400 });
const file = resolveAsset(rel);
if (!file) return new Response('Not found', { status: 404 });
const { size } = statSync(file);
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
return new Response(stream, {
headers: {
'Content-Type': type,
'Content-Length': String(size),
'Cache-Control': 'private, max-age=86400',
},
});
};
@@ -0,0 +1,43 @@
---
import Campus from '../../layouts/Campus.astro';
import { getNav } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
---
<Campus title="Guida allo studio" nav={nav}>
<p class="eyebrow">Biohacking Campus 2025/2026</p>
<h1>Guida allo studio</h1>
<p class="cp-lead">
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
</p>
<div class="cp-grid">
{nav.areas.map((area) => (
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
<h2>{area.title}</h2>
<p>{area.sub}</p>
<ul>
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
</ul>
</a>
))}
</div>
</Campus>
<style is:global>
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
text-transform: uppercase; }
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
font-size: .82rem; color: var(--c-text-light); }
.cp-card ul li { margin: 0; padding: 2px 0; }
.cp-card ul span { color: var(--c-accent-dark); }
</style>
+7
View File
@@ -0,0 +1,7 @@
---
// La radice di apps.insanitylab.it non ha contenuto proprio: ognuno va a casa sua, e chi
// non è loggato ci finisce comunque passando dal middleware.
import { landingFor } from '../lib/auth';
export const prerender = false;
return Astro.redirect(landingFor(Astro.locals.user!.role));
---
+56
View File
@@ -0,0 +1,56 @@
---
// Il login di quest'app. ⚠️ Non è quello del sito: utenti e sessioni stanno in
// piattaforme.db, e chi è loggato su insanitylab.it qui è uno sconosciuto.
import App from '../layouts/App.astro';
import { getDb } from '../lib/db';
import { login, getSessionUser, SESSION_COOKIE, landingFor } from '../lib/auth';
import { safeNext } from '../lib/safe-next';
export const prerender = false;
const dest = safeNext(Astro.url.searchParams.get('next'));
const esistente = Astro.cookies.get(SESSION_COOKIE)?.value;
const giaDentro = esistente ? getSessionUser(getDb(), esistente) : null;
if (giaDentro) return Astro.redirect(dest !== '/' ? dest : landingFor(giaDentro.role));
let errore = '';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
if (token) {
Astro.cookies.set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/',
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
});
const utente = getSessionUser(getDb(), token)!;
const richiesto = safeNext(String(form.get('next') ?? '') || dest);
return Astro.redirect(richiesto !== '/' ? richiesto : landingFor(utente.role));
}
errore = 'Credenziali non valide.';
}
---
<App title="Accedi">
<section class="login">
<h1>Area riservata</h1>
{errore && <p class="login__err">{errore}</p>}
<form method="post" class="login__form">
<input type="hidden" name="next" value={dest} />
<input class="login__field" name="username" placeholder="Utente" required autocomplete="username" />
<input class="login__field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button class="login__btn" type="submit">Accedi</button>
</form>
<p class="login__back"><a href="https://insanitylab.it">← Torna al sito</a></p>
</section>
<style>
.login { max-width: 22rem; margin: 3rem auto; }
.login h1 { font-family: var(--font-heading, inherit); font-size: 1.2rem; letter-spacing: .12em;
text-transform: uppercase; margin-bottom: 1.6rem; }
.login__form { display: grid; gap: .8rem; }
.login__field { padding: .7rem .8rem; border: 1px solid rgba(0,0,0,.2); border-radius: 2px; font: inherit; }
.login__btn { padding: .75rem; border: 0; background: var(--c-heading, #111); color: #fff; font: inherit;
letter-spacing: .12em; text-transform: uppercase; font-size: .74rem; cursor: pointer; }
.login__err { color: #b00020; font-size: .85rem; }
.login__back { margin-top: 1.6rem; font-size: .8rem; }
.login__back a { color: var(--c-muted, #777); }
</style>
</App>
+11
View File
@@ -0,0 +1,11 @@
import type { APIRoute } from 'astro';
import { getDb } from '../lib/db';
import { logout, SESSION_COOKIE } from '../lib/auth';
export const prerender = false;
export const GET: APIRoute = ({ cookies, redirect }) => {
const token = cookies.get(SESSION_COOKIE)?.value;
if (token) logout(getDb(), token);
cookies.delete(SESSION_COOKIE, { path: '/' });
return redirect('/login');
};
@@ -0,0 +1,7 @@
---
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath
// e canAccessAdminPath in src/lib/auth.ts).
export const prerender = false;
return Astro.redirect('/longevity/io');
---
@@ -0,0 +1,72 @@
---
// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score.
// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice
// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a
// toccare identity), la sessione più recente e il referto stesso da vista.ts.
import Longevity from '../../layouts/Longevity.astro';
import Radar from '../../components/longevity/Radar';
import MacroScore from '../../components/longevity/MacroScore.astro';
import { getLongevityDb } from '../../lib/longevity/db';
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista';
import { formattaNumero } from '../../lib/longevity/formato';
export const prerender = false;
const user = Astro.locals.user!;
const codice = codicePerUtenteLoggato(user.id);
const db = getLongevityDb();
const sessioneId = codice ? ultimaSessioneDi(db, codice) : null;
const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
---
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
{!codice && (
<div class="lg-card">
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
</div>
)}
{codice && !referto && (
<div class="lg-card lg-card--incompleto">
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
</div>
)}
{referto && (
<div class="lg-referto">
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
<p class="lg-score__label">Fitness Age</p>
<div class="lg-score">
<span class="lg-score__value lg-num">
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
</span>
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
</div>
<p class="lg-muted lg-macro__nota">
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
</p>
</section>
<section class="lg-card">
<h2>Il tuo radar</h2>
<Radar client:load assi={referto.assi} />
</section>
<section class="lg-macro">
{referto.macro.map((m) => (
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
))}
</section>
<section class="lg-card">
<p>
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
raccontano come cambi, non solo come stai oggi.
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
</p>
</section>
</div>
)}
</Longevity>
@@ -0,0 +1,22 @@
---
// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
// /api/longevity/questionario, dove il consenso si verifica davvero.
import Longevity from '../../layouts/Longevity.astro';
import Questionario from '../../components/longevity/Questionario';
import { getLongevityDb } from '../../lib/longevity/db';
import { campiDelQuestionario } from '../../lib/longevity/vista';
export const prerender = false;
const oggi = new Date().toISOString().slice(0, 10);
const campi = campiDelQuestionario(getLongevityDb(), oggi);
---
<Longevity title="Questionario" crumbs={['Questionario']}>
<div class="lg-head">
<h1>Questionario</h1>
<p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
</div>
<Questionario client:load campi={campi} />
</Longevity>
@@ -0,0 +1,49 @@
---
// Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme`
// (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware.
import App from '../../layouts/App.astro';
import { piattaformePer } from '../../data/piattaforme';
export const prerender = false;
const utente = Astro.locals.user!;
const visibili = piattaformePer(utente.role);
---
<App title="Piattaforme" user={utente}>
<section class="section plat-index">
<div class="container">
<p class="plat-index__eyebrow">Area riservata</p>
<h1>Piattaforme</h1>
<p class="plat-index__lead">Gli strumenti riservati agli utenti abilitati.</p>
<div class="plat-index__grid">
{visibili.map((p) => (
<a class="plat-card" href={p.href}>
<h2 class="plat-card__title">{p.label}</h2>
<p class="plat-card__desc">{p.description}</p>
<span class="plat-card__cta">Entra</span>
</a>
))}
</div>
</div>
</section>
</App>
<style>
.plat-index__eyebrow {
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; color: var(--c-accent-dark); margin: 0 0 .6em;
}
.plat-index__lead { max-width: 46ch; color: var(--c-text-light); margin-bottom: 46px; }
.plat-index__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
.plat-card {
display: block; text-decoration: none; background: var(--c-bg-alt); padding: 34px 30px 30px;
border-top: 3px solid var(--c-accent); transition: transform .3s ease, box-shadow .3s ease;
}
.plat-card:hover { transform: translateY(-4px); box-shadow: 0 14px 30px rgba(0,0,0,.08); }
.plat-card__title { font-size: 1.25rem; margin-bottom: .4em; }
.plat-card__desc { color: var(--c-text-light); font-size: .92rem; margin-bottom: 1.4em; }
.plat-card__cta {
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; color: var(--c-accent-dark);
}
.plat-card__cta::after { content: ' →'; }
</style>
@@ -0,0 +1,22 @@
---
// Analytics di studio. La pagina fornisce i dati e l'intervallo iniziale; il resto — filtri,
// aggregazioni e grafici — è nell'isola, perché ogni numero dipende dai controlli.
import StressIndex from '../../../layouts/StressIndex.astro';
import AnalyticsView from '../../../components/stress-index/AnalyticsView';
import { clients, measurementsByClient, alerts } from '../../../lib/stress-index/data';
export const prerender = false;
// Mese corrente dei dati dimostrativi.
const DA = '2026-07-01';
const A = '2026-07-31';
---
<StressIndex title="Analytics" crumbs={['Analytics']}>
<AnalyticsView
client:load
clients={clients}
measurementsByClient={measurementsByClient}
alerts={alerts}
from={DA}
to={A}
/>
</StressIndex>
@@ -0,0 +1,21 @@
---
// Scheda del singolo cliente. La pagina raccoglie i dati e li passa all'isola React che
// gestisce tab e interazioni.
import StressIndex from '../../../../layouts/StressIndex.astro';
import ClientDetail from '../../../../components/stress-index/ClientDetail';
import { findClient, measurementsByClient, alerts, notes } from '../../../../lib/stress-index/data';
export const prerender = false;
const { id } = Astro.params;
const client = findClient(id ?? '');
if (!client) return Astro.redirect('/piattaforme/stress-index/clienti');
---
<StressIndex title={client.name} crumbs={['Clienti', client.name]}>
<ClientDetail
client:load
client={client}
measurements={measurementsByClient[client.id] ?? []}
alerts={alerts.filter((a) => a.clientId === client.id)}
notes={notes.filter((n) => n.clientId === client.id)}
/>
</StressIndex>
@@ -0,0 +1,89 @@
---
// Elenco clienti (CRM). Il filtro per nome/email è puro JS sulle righe già renderizzate:
// i clienti sono una decina, non vale un'isola React per cercarli.
import StressIndex from '../../../../layouts/StressIndex.astro';
import Card from '../../../../components/stress-index/Card.astro';
import { clients } from '../../../../lib/stress-index/data';
export const prerender = false;
const livello = (stress: number) => (stress >= 70 ? 'bad' : stress >= 45 ? 'warn' : 'ok');
---
<StressIndex title="Clienti" crumbs={['Clienti']}>
<div class="si-head">
<h1>Clienti</h1>
<p>{clients.length} in carico</p>
</div>
<Card>
<div class="si-toolbar">
<label class="si-field si-field--inline">
<span class="si-visually-hidden">Cerca cliente</span>
<input type="search" id="si-cerca" placeholder="Cerca per nome o email" autocomplete="off" />
</label>
<p class="si-muted si-small" id="si-conteggio">{clients.length} risultati</p>
</div>
<table class="si-table" id="si-tabella-clienti">
<thead>
<tr>
<th>Cliente</th><th>Età</th><th>Tag</th><th>Ultima misurazione</th><th>Stress</th><th>Alert</th>
</tr>
</thead>
<tbody>
{clients.map((c) => (
<tr data-cerca={`${c.name} ${c.email}`.toLowerCase()}>
<td>
<a class="si-client" href={`/piattaforme/stress-index/clienti/${c.id}`}>
<span class="si-avatar">{c.initials}</span>
<span>
<span class="si-client__name">{c.name}</span>
<span class="si-client__mail">{c.email}</span>
</span>
</a>
</td>
<td>{c.age} anni</td>
<td>{c.tags[0] && <span class="si-tag">{c.tags[0]}</span>}</td>
<td>{c.lastMeasurement}</td>
<td>
<div class="si-meter">
<span class="si-bar">
<span class:list={['si-bar__fill', `si-bar__fill--${livello(c.stress)}`]} style={`width:${c.stress}%`}></span>
</span>
<span class="si-table__num">{c.stress}</span>
</div>
</td>
<td>
{c.alerts > 0
? <span class:list={['si-badge', c.alerts >= 2 ? 'si-badge--bad' : 'si-badge--warn']}>{c.alerts}</span>
: <span class="si-muted">—</span>}
</td>
</tr>
))}
</tbody>
</table>
<p class="si-muted si-small si-empty" id="si-vuoto" hidden>Nessun cliente corrisponde alla ricerca.</p>
</Card>
</StressIndex>
<script>
// Filtro dell'elenco: nasconde le righe che non contengono il testo cercato e tiene
// aggiornato il conteggio dei risultati.
const input = document.getElementById('si-cerca') as HTMLInputElement | null;
const table = document.getElementById('si-tabella-clienti');
const counter = document.getElementById('si-conteggio');
const empty = document.getElementById('si-vuoto');
if (input && table && counter && empty) {
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>('tbody tr'));
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase();
let visible = 0;
for (const row of rows) {
const match = !q || (row.dataset.cerca ?? '').includes(q);
row.hidden = !match;
if (match) visible += 1;
}
counter.textContent = `${visible} ${visible === 1 ? 'risultato' : 'risultati'}`;
empty.hidden = visible > 0;
});
}
</script>
@@ -0,0 +1,121 @@
---
// Impostazioni: profilo, notifiche, account. I campi sono ancora un modulo dimostrativo —
// non c'è un endpoint di salvataggio — ma la struttura è quella definitiva.
import StressIndex from '../../../layouts/StressIndex.astro';
import Card from '../../../components/stress-index/Card.astro';
import { professional } from '../../../lib/stress-index/data';
export const prerender = false;
const [nome = '', cognome = ''] = professional.name.replace(/^Dr\.?\s*/i, '').split(' ');
const GIORNI = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'];
const ORE = ['07:00', '08:00', '09:00', '18:00', '19:00', '20:00'];
const TABS = [
{ key: 'profilo', label: 'Profilo' },
{ key: 'notifiche', label: 'Notifiche' },
{ key: 'account', label: 'Account' },
];
---
<StressIndex title="Impostazioni" crumbs={['Impostazioni']}>
<div class="si-head">
<h1>Impostazioni</h1>
<p>Profilo, notifiche e account</p>
</div>
<div class="si-tabs" role="tablist" id="si-set-tabs">
{TABS.map((t, i) => (
<button type="button" role="tab" class="si-tabs__btn" data-tab={t.key} aria-selected={i === 0 ? 'true' : 'false'}>{t.label}</button>
))}
</div>
<section data-panel="profilo" class="si-stack">
<Card title="Dati professionista">
<div class="si-form-grid">
<label class="si-field">Titolo<input type="text" value="Dr." /></label>
<label class="si-field">Professione<input type="text" value={professional.role} /></label>
<label class="si-field">Nome<input type="text" value={nome} /></label>
<label class="si-field">Cognome<input type="text" value={cognome} /></label>
<label class="si-field">Specializzazione<input type="text" value={professional.role} /></label>
</div>
</Card>
<Card title="Studio">
<div class="si-form-grid">
<label class="si-field">Nome studio<input type="text" value={professional.studio} /></label>
<label class="si-field">Indirizzo<input type="text" placeholder="Via Roma 1, Bologna" /></label>
<label class="si-field">Telefono<input type="tel" placeholder="+39 051 123456" /></label>
<label class="si-field">Sito web<input type="url" placeholder="https://studio.it" /></label>
</div>
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva</button></p>
</Card>
</section>
<section data-panel="notifiche" hidden>
<Card title="Preferenze di notifica">
<ul class="si-list si-list--rows si-prefs">
<li>
<span>
<strong>Riassunto settimanale via email</strong>
<span class="si-muted si-small">Un riepilogo periodico dell'andamento dei clienti</span>
</span>
<input type="checkbox" checked />
</li>
<li>
<span>
<strong>Alert immediati</strong>
<span class="si-muted si-small">Email non appena viene generato un alert cliente</span>
</span>
<input type="checkbox" checked />
</li>
<li>
<span>
<strong>Novità di prodotto</strong>
<span class="si-muted si-small">Aggiornamenti sulle nuove funzionalità</span>
</span>
<input type="checkbox" />
</li>
</ul>
<div class="si-form-grid si-mt">
<label class="si-field">Giorno dell'invio
<select>{GIORNI.map((g) => <option>{g}</option>)}</select>
</label>
<label class="si-field">Ora dell'invio
<select>{ORE.map((o) => <option>{o}</option>)}</select>
</label>
</div>
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva preferenze</button></p>
</Card>
</section>
<section data-panel="account" hidden class="si-stack">
<Card title="Accesso">
<label class="si-field">Email<input type="email" value={professional.email} readonly /></label>
<p class="si-muted si-small si-mt">Per cambiare l'indirizzo email serve una richiesta al supporto.</p>
<div class="si-form-grid si-mt">
<label class="si-field">Password attuale<input type="password" autocomplete="current-password" /></label>
<label class="si-field">Nuova password<input type="password" autocomplete="new-password" /></label>
<label class="si-field">Conferma nuova password<input type="password" autocomplete="new-password" /></label>
</div>
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Aggiorna password</button></p>
</Card>
<Card title="Abbonamento">
<p class="si-muted si-small">Per il piano e la fatturazione fa riferimento al supporto Stress Index.</p>
</Card>
<p><a class="si-btn" href="/admin/logout">Esci dall'account</a></p>
</section>
</StressIndex>
<script>
const tabs = document.getElementById('si-set-tabs');
if (tabs) {
const buttons = Array.from(tabs.querySelectorAll<HTMLButtonElement>('.si-tabs__btn'));
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
for (const btn of buttons) {
btn.addEventListener('click', () => {
for (const b of buttons) b.setAttribute('aria-selected', String(b === btn));
for (const p of panels) p.hidden = p.dataset.panel !== btn.dataset.tab;
});
}
}
</script>
@@ -0,0 +1,88 @@
---
// Dashboard "Oggi": ciò che il professionista deve guardare per primo — alert, clienti da
// ricontattare, note recenti. L'accesso è già filtrato dal middleware (ruolo piattaforme/admin).
import StressIndex from '../../../layouts/StressIndex.astro';
import Card from '../../../components/stress-index/Card.astro';
import Kpi from '../../../components/stress-index/Kpi.astro';
import AlertRow from '../../../components/stress-index/AlertRow.astro';
import AndamentoChart from '../../../components/stress-index/charts/AndamentoChart';
import {
alerts, clients, measurementsByClient, notes, studioKpis, professional, OGGI, daysSince,
} from '../../../lib/stress-index/data';
export const prerender = false;
const prioritari = alerts.slice(0, 4);
const misurazioniOggi = Object.values(measurementsByClient).flat().filter((m) => m.date === OGGI).length;
const daContattare = [...clients]
.sort((a, b) => daysSince(b.lastMeasurement) - daysSince(a.lastMeasurement))
.slice(0, 5);
const ultimeNote = [...notes]
.sort((a, b) => (a.when < b.when ? 1 : -1))
.slice(0, 4)
.map((n) => ({ ...n, clientName: clients.find((c) => c.id === n.clientId)?.name }));
---
<StressIndex title="Oggi">
<div class="si-head">
<h1>Buongiorno, {professional.name.replace(/^Dr\.\s*/, '')}</h1>
<p>Oggi, {OGGI}</p>
</div>
<div class="si-grid si-grid--kpi">
<Kpi label="Clienti attivi" value={studioKpis.totalClients} delta="+2 questo mese" trend="up" />
<Kpi
label="Misurazioni oggi"
value={misurazioniOggi}
delta={misurazioniOggi === 0 ? 'Nessuna ancora' : 'In aggiornamento'}
trend={misurazioniOggi === 0 ? 'down' : 'up'}
/>
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} delta="+3 questa settimana" trend="down" />
<Kpi label="Stress medio" value={studioKpis.avgStress} delta="-4% vs mese scorso" trend="up" />
</div>
<div class="si-grid si-grid--2 si-mt">
<div class="si-stack">
<Card title="Andamento medio aggregato">
<AndamentoChart client:load measurementsByClient={measurementsByClient} />
</Card>
<Card title="Alert prioritari" actionLabel="Vedi tutti" actionHref="/piattaforme/stress-index/clienti">
{prioritari.map((a) => <AlertRow alert={a} />)}
</Card>
<Card title="Da contattare">
<ul class="si-list si-list--rows">
{daContattare.map((c) => (
<li>
<span>
<strong>{c.name}</strong>
<span class="si-muted"> · ultima misurazione {daysSince(c.lastMeasurement)} giorni fa</span>
</span>
<a class="si-btn" href={`/piattaforme/stress-index/clienti/${c.id}`}>Scheda</a>
</li>
))}
</ul>
</Card>
</div>
<div class="si-stack">
<Card title="Misurazioni di oggi">
{misurazioniOggi === 0 ? (
<p class="si-muted si-small">Nessuna misurazione registrata oggi. Le rilevazioni dei clienti compaiono qui appena arrivano.</p>
) : (
<p class="si-kpi__value">{misurazioniOggi}</p>
)}
</Card>
<Card title="Ultime note">
<ul class="si-list">
{ultimeNote.map((n) => (
<li class="si-note">
<p>{n.text}</p>
<p class="si-note__meta">{n.clientName ?? 'Nota generale'} · {n.when}</p>
</li>
))}
</ul>
</Card>
</div>
</div>
</StressIndex>
@@ -0,0 +1,33 @@
---
// Organizzazione: raggruppa più professionisti sotto un'unica struttura. Finché non c'è
// il backend, il modulo raccoglie solo il nome e il pulsante resta disabilitato a vuoto.
import StressIndex from '../../../layouts/StressIndex.astro';
export const prerender = false;
---
<StressIndex title="Organizzazione" crumbs={['Organizzazione']}>
<div class="si-center">
<div class="si-card si-org">
<h1>Crea la tua organizzazione</h1>
<p class="si-muted si-small">
Raggruppa più professionisti sotto un'unica organizzazione per condividere clienti,
dati e impostazioni dello studio.
</p>
<label class="si-field si-mt">
Nome organizzazione
<input type="text" id="si-org-nome" placeholder="Studio Bianchi Associati" />
</label>
<p class="si-mt">
<button type="button" class="si-btn si-btn--solid si-org__cta" id="si-org-crea" disabled>Crea organizzazione</button>
</p>
</div>
</div>
</StressIndex>
<script>
// Il pulsante si sblocca solo con un nome valorizzato.
const input = document.getElementById('si-org-nome') as HTMLInputElement | null;
const btn = document.getElementById('si-org-crea') as HTMLButtonElement | null;
if (input && btn) {
input.addEventListener('input', () => { btn.disabled = input.value.trim().length === 0; });
}
</script>
@@ -0,0 +1,13 @@
---
// Modulo Sport. La pagina passa i dati; filtri, schede e aggregazioni stanno nell'isola.
import StressIndex from '../../../../layouts/StressIndex.astro';
import SportView from '../../../../components/stress-index/SportView';
import { sessions, athletes } from '../../../../lib/stress-index/data';
export const prerender = false;
// Ultimo giorno coperto dai dati dimostrativi.
const RIFERIMENTO = '2026-07-12';
---
<StressIndex title="Sport" crumbs={['Sport']}>
<SportView client:load sessions={sessions} athletes={athletes} riferimento={RIFERIMENTO} />
</StressIndex>
@@ -0,0 +1,43 @@
---
// Monitoraggio live: finché non arriva la sorgente dati in tempo reale la vista resta
// vuota per costruzione, e spiega cosa succederà quando un atleta avvia una sessione.
import StressIndex from '../../../../layouts/StressIndex.astro';
export const prerender = false;
const PASSI = [
"L'atleta avvia una sessione dall'app Stress Index collegata alla fascia cardio.",
'I dati di frequenza cardiaca e DFA A1 arrivano in tempo reale a questa vista.',
"La scheda dell'atleta compare qui da sola, con le metriche aggiornate in continuo.",
'A fine sessione la scheda si chiude e il riepilogo confluisce nello storico Sport.',
];
---
<StressIndex title="Team live" crumbs={['Sport', 'Team live']}>
<div class="si-head si-head--row">
<div>
<h1>Team live</h1>
<p>Nessun atleta in sessione</p>
</div>
<label class="si-field si-field--inline">
<span class="si-visually-hidden">Ordina per</span>
<select>
<option value="nome">Ordina per nome</option>
<option value="hr">Ordina per HR corrente</option>
<option value="carico">Ordina per carico sessione</option>
</select>
</label>
</div>
<div class="si-card si-live">
<p class="si-live__title">Nessun atleta in sessione</p>
<p class="si-muted si-small">Quando un atleta avvia una sessione dall'app, la sua scheda compare qui in tempo reale.</p>
<div class="si-live__how">
<p class="si-card__title">Come funziona</p>
<ol class="si-steps">
{PASSI.map((p, i) => (
<li><span class="si-steps__num">{i + 1}</span><span>{p}</span></li>
))}
</ol>
</div>
</div>
</StressIndex>