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:
@@ -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>
|
||||
@@ -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));
|
||||
---
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user