244 Commits

Author SHA1 Message Date
Adriano 59dff13e22 Consulenza: bottone nel menu in alto su ogni pagina, voce nel menu mobile (#22)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 05:19:40 +00:00
Adriano 5c39b04c31 Consulenza: la nota sta sotto il suo bottone, non sotto Contattaci (#22)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:39:15 +00:00
Adriano ae98c4b889 Consulenza: nota sotto i bottoni con lo stile giusto (:global sul componente T) (#22)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:32:54 +00:00
Adriano c65effb0ed Consulenza: sotto il bottone la sede, via Leandro Alberti 76 (#22)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:32:07 +00:00
Adriano 67dc554810 Reformer Class: bottone consulenza conoscitiva gratuita (#22)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:30:09 +00:00
Adriano 9126af28ce Reformer Class: bottone calendario taupe, meno scuro (#19)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:50:48 +00:00
Adriano 3fff3cd2f8 Il repo resta al sito: app in radice, piattaforme nel loro repo
apps/sito passa in radice, apps/platforms si toglie: vive in
gitea:Adriano/InsanityLab-platforms, con la storia del repo comune. I piani e
le specifiche di Longevity e Campus lasciano docs/ e seguono le piattaforme.
README e compose: deploy da /opt/docker/insanitylab/src; al primo deploy .env
va spostato da apps/sito/.env alla radice.

Refs #20

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:25:12 +00:00
Adriano 9efd9a7680 Reformer Class: bottone Contattaci e calendario evidenziato
Refs #19

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:06:40 +00:00
Adriano 680224e773 longevity: #15 bersagli touch >=44px per radio, pulsanti e link-pulsante
Tablet del centro: label di radio (lg-field--consenso, alternative del
check-up), button di lg-form__nav e lg-btn a min-height 44px, senza
cambiare l'aspetto desktop.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:36 +02:00
Adriano 8aeb25f930 longevity: #15 pagine del flusso guidato: consulenza, check-up, scheda cliente
Nuova pagina consulenza.astro (radio per livello e obiettivo, niente
select). checkup.astro mostra "Passo 3 di 4" e porta avanti flusso=nuovo,
con «Salta» verso la scheda. La scheda cliente ([code].astro) mostra la
sezione "Prima consulenza" (letta, Modifica/Compila) e, con
flusso=questionario, il riquadro "Passo 4 di 4" che rimanda a "Crea
accesso" se il cliente non lo ha ancora.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:33 +02:00
Adriano 575a3988b7 longevity: #15 API della prima consulenza e flusso guidato nel check-up
Nuova API consulenza.ts (POST, autore da locals.user, redirect a
checkup?flusso=nuovo o alla scheda cliente). clienti.ts ora apre il flusso
guidato verso /consulenza?flusso=nuovo invece della scheda. checkup.ts:
un check-up NUOVO con flusso=nuovo prosegue con flusso=questionario; una
correzione lo ignora sempre.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:28 +02:00
Adriano 498395dddb longevity: #15 libreria e schema della prima consulenza, con test
Tabella consulenza (client_code, UPSERT, nessuno storico) e modulo
lib/longevity/consulenza.ts: consulenzaDi, salvaConsulenza con validazioni
(livello 1-3, obiettivo nell'elenco chiuso, data AAAA-MM-GG, disponibilita
0-7, testo max 5000). 22 test: modulo, schema, API consulenza.ts e
checkup.ts (flusso guidato), redirect di clienti.ts, nessuna pagina cliente
importa consulenza.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:21:24 +02:00
Adriano b0f30f9e4c longevity: correzioni passo 3 su #13 unità B — scarto alternative e nota
testIdScartatiDalleAlternative: una sezione senza scelta in `scelte` (radio
assente dal form) non scarta più nulla, invece di ripiegare in silenzio
sulla prima alternativa (perdeva campi come f_plank_sec senza alt_core).

API checkup.ts: la nota operatore si valida (lunghezza max 5000, TESTO_MAX
esportato da giornale.ts) PRIMA di salvare il check-up, non più dopo — una
nota troppo lunga non faceva più fallire aggiungiVoce in silenzio ma
perdeva comunque la nota col check-up già scritto. maxlength sulla
textarea in checkup.astro.

Suite: 314 verdi, 4 rossi di piattaforma (baseline, invariati). tsc e
build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:59:19 +02:00
Adriano 81021d18f3 longevity: test unità B — struttura modulo, alternative, nota su giornale (#13)
gestionale-modulo-checkup.test.ts: ordine sezioni, pressione/SpO2 esclusi,
dismesso in «Altre misure» su data vecchia, dettagli MUTT, preselezione
alternativa (prima/più recente/ultimo check-up). checkup-alternative-api.test.ts:
API scarta i campi dell'alternativa non scelta (radio inviata o assente),
nota operatore aggiunge voce di giornale (data check-up, autore operatore),
nota vuota o assente (correzione) non aggiunge nulla.

Suite completa: 313 verdi, 4 rossi di piattaforma (baseline, invariati).
tsc --noEmit e npm run build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:57:02 +02:00
Adriano c8187ce4db longevity: modulo check-up con sezioni e alternative del manuale (#13)
strutturaCheckup in gestionale.ts (ordine Pre-test..Strumenti, sezioni ad
alternative con soglia testuale, «Altre misure» per i test dismessi in
correzione, pressione/SpO2 esclusi); checkup.astro con radio button e
fieldset (niente <select>); API checkup.ts scarta i campi delle
alternative non scelte e aggiunge una voce di giornale dalla nota
operatore sul check-up nuovo.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:54:26 +02:00
Adriano cffb0a624d longevity: correzione etichette sit-to-stand e push-up (unità A)
sit-to-stand era descritto come 30 secondi, il test dura 1 minuto;
push-up non diceva che le donne lo fanno sulle ginocchia.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:49:24 +02:00
Adriano f57a07a964 longevity: test unitari e di sessione per la regola dei gruppi (#13)
motore-fisici-gruppi.test.ts (nuovo): un caso per ogni alternativa che da
punteggio (handgrip/spinta/arti_inferiori/vo2max/hrr/flamingo/back_scratch,
media dx/sx e storico), un lato solo -> nessun punteggio, recenza fra
alternative (pushup/panca in entrambe le direzioni), alternativa piu recente
ma incompleta esclusa (sit-to-stand senza altezza), i test "solo registrati"
non producono chiavi.

motore-fisici-sessione.test.ts: due nuovi casi end-to-end su DB (due sessioni,
due test diversi per la stessa sotto-metrica) che confermano che lo score usa
il test piu recente, in entrambe le direzioni.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:48:07 +02:00
Adriano abd0626ab0 longevity: registro e motore per la nuova batteria test (manuale Nicola 27/09)
Nuovi test_id nel registro (altezza, FC riposo, handgrip/back scratch/flamingo
dx-sx, panca manubri/bilanciere, trazioni, sit-to-stand, plank ginocchia,
6MWT, 2km walk UKK, MILFIT, MUTT FC max/1'/2'/step) con attivo_da, e i quattro
test_id storici sostituiti (handgrip_kg, back_scratch_cm, flamingo_cadute,
hrr_1min_bpm) marcati attivo_a 2026-09-28 senza cancellarli.

motore/index.ts: nuova recenzaValideAlla (posizione di recenza per test_id,
stesso ORDER BY di misureValideAlla, fattorizzata la query condivisa).

motore/fisici.ts: regola dei gruppi in sottoDominiFisici (recenza opzionale) -
per ogni sotto-metrica con piu' test alternativi si sceglie l'alternativa
COMPLETA la cui misura piu' recente vince; senza recenza resta l'ordine di
priorita' di oggi. Handgrip/back_scratch/flamingo ora mediano dx/sx; spinta e
arti_inferiori aggiungono panca bilanciere e sit-to-stand; vo2max aggiunge
MILFIT e 2km walk alla cascata esistente.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:45:26 +02:00
Adriano 2231a476c7 longevity: sezione Giornale di bordo nella scheda cliente
Form (data, nota) ed elenco voci dopo Anagrafica, cancella visibile solo ad
autore o admin, messaggi per ?giornale=aggiunta|cancellata.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:24:22 +02:00
Adriano cef08aaf9f longevity: API giornale di bordo per cliente (aggiungi/cancella)
POST unico con campo azione, autore e ruolo sempre da locals.user, redirect
303 alla scheda cliente #giornale.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:23:03 +02:00
Adriano 37b1a316d8 longevity: test unitari giornale di bordo per cliente
Aggiunta/lettura, rifiuti di aggiungiVoce, cancellaVoce (autore/admin/altro
trainer/id inesistente), schema idempotente, nessuna pagina cliente importa
giornale.ts.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:22:40 +02:00
Adriano 00ecfe86c3 longevity: schema e libreria giornale di bordo per cliente
Tabella giornale in longevity.db (solo client_code) e giornale.ts:
vociDelGiornale, aggiungiVoce (validazioni), cancellaVoce (autore o admin).
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:22:07 +02:00
Adriano 6bbfcd8f3c longevity: pagina dettaglio sessione e modifica check-up dalla scheda (#12)
Nuova pagina .../sessione/[id].astro: dettaglio misure per asse, con
link "Modifica" per un checkup. checkup.astro diventa correzione con
?sessione=<id> (campi precompilati, attivi alla data della sessione).
La scheda cliente linka ogni sessione e mostra l'esito della correzione.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
2026-09-27 22:13:41 +02:00
Adriano 9e566ad7b1 longevity: API checkup accetta sessione_id per correggere (#12)
Un sessione_id nel form fa passare da correggiCheckup invece di
salvaCheckup, stesso endpoint. Il client_code per validazioni e
redirect si legge sempre dalla sessione nel DB, mai dal form.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
2026-09-27 22:12:00 +02:00
Adriano 19035e67b0 longevity: correggiCheckup e misureDellaSessione, sovrascrittura misure
Aggiunge la correzione di un check-up gia' salvato: aggiorna sessione e
misure (cambiate/tolte/nuove), azzera la verifica sulle misure cambiate,
ricalcola i punteggi della sessione e delle successive. Estratto
fuoriRangeDi in misure.ts per non duplicare il calcolo min/max.

Refs #12

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkWhSnSLvAyRCoWBuCpXvp
2026-09-27 22:09:15 +02:00
Adriano 880c928f43 docs: README e spec Longevity al 26/09
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:02:23 +00:00
Adriano 558def3d52 docs: README piattaforme e spec Longevity aggiornati al gestionale in produzione
README: host piattaforme.tielogic.xyz, conteggio test, sezione Longevity (ruoli,
pagine, regola della misura valida più recente), procedura di aggiornamento con
backup, dati nel volume, apertura pigra del DB, trappole dei test e node_modules.
Spec: §13 con i punti di §11/§12 chiusi da #1 #2 #5 #6 #7 #8 e quelli ancora aperti;
decisioni del 03/09 e 26/09.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:02:22 +00:00
Adriano 8f95a385d9 Longevity: nessuna pagina senza uscita (#10)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:48:42 +00:00
Adriano 22a400b854 longevity: nessuna pagina senza uscita, "Il mio referto" solo a chi ha un fascicolo
Refs #10

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:48:03 +00:00
Adriano 12b2e91503 Longevity: gestionale trainer, test fisici, progressione, verifica misure, accesso cliente (#1 #2 #5 #7 #8 #9)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:15:29 +00:00
Adriano e74266f9a0 node_modules fuori da git: il symlink tracciato puntava al checkout di produzione
Il pattern 'node_modules/' ignora solo le cartelle, non i symlink: un symlink
apps/platforms/node_modules -> /opt/docker/insanitylab-apps/... era finito in
git (13d8cdd). Nei cloni npm ci passava dal symlink e svuotava la cartella del
checkout di produzione; nel checkout di produzione il symlink punterebbe a se'
stesso. Ora il pattern e' 'node_modules' (cartelle e symlink).

Fixes #9

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:15:24 +00:00
Adriano 2c9a74f849 longevity: verifica misure fuori range in scheda cliente, link a crea accesso
Scheda cliente ([code].astro): sezione "Misure da verificare" da
misureDaVerificare (id, range) al posto di misureFuoriRange (nessun id,
nessuna azione); ogni riga ha due form verso /api/longevity/gestionale/verifica
(conferma/scarta), messaggio dopo la decisione, e link "Crea accesso al
referto" quando il cliente non ha ancora un accesso.

gestionale.ts: rimossa misureFuoriRange (e il tipo MisuraFuoriRange), sostituita
ovunque da misureDaVerificare (misure.ts), l'unica con id e range utile alle
azioni. accesso.astro: link "Torna alla scheda" in tutti gli stati.

Refs #7
Refs #8

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:11:38 +00:00
Adriano 6cbb205064 Merge branch 'lavoro/goldrake-i8' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano 8579199115 Merge branch 'lavoro/goldrake-i7' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano d2b508e8ac Merge branch 'lavoro/goldrake-i5' into lavoro/goldrake 2026-09-26 13:06:22 +00:00
Adriano 948068b312 longevity: index.astro smista per ruolo, test del gestionale
pages/longevity/index.astro: trainer e admin → /longevity/gestionale, cliente →
/longevity/io come prima (l'assert esistente sul redirect a /longevity/io resta
valido, il ramo cliente lo chiama ancora testualmente).
tests/longevity/pagine.test.ts: le quattro pagine del gestionale esistono, usano
variante="gestionale", non toccano il DB direttamente e non nominano il database
anagrafico riservato; index.astro smista per ruolo.

Refs #2
2026-09-26 13:05:52 +00:00
Adriano 9375a0380d longevity: pagina del gestionale — modulo di check-up manuale
pages/longevity/gestionale/cliente/[code]/checkup.astro: campi da campiDelCheckup,
raggruppati per asse, etichetta e unità, input inputmode="decimal" tutti facoltativi
(la virgola decimale e i campi vuoti li gestisce l'endpoint). Invio verso
/api/longevity/gestionale/checkup.

Refs #2
2026-09-26 13:05:47 +00:00
Adriano 179aeb442a longevity: pagina del gestionale — scheda cliente (referto, sessioni, fuori range)
pages/longevity/gestionale/cliente/[code].astro: anagrafica, il referto dell'ultima
sessione (stesso Radar/MacroScore di io.astro, qui per il trainer), la tabella delle
sessioni e le misure fuori range (colore --lg-fuori-range). Un codice inesistente è
404. Gli esiti ?salvato/?fuori/?errore dell'API di check-up sono mostrati qui.

Refs #2
2026-09-26 13:05:42 +00:00
Adriano fd56afdf44 longevity: pagine del gestionale — elenco clienti e apertura fascicolo
pages/longevity/gestionale/index.astro: tabella clienti (codice, cognome nome, età,
accesso, data e tipo dell'ultima sessione via sessioniDi per cliente — va bene finché
i fascicoli sono pochi, non pensata per scalare).
pages/longevity/gestionale/nuovo.astro: form verso /api/longevity/gestionale/clienti,
sesso a radio (non <select>: la parola inglese avrebbe fatto scattare il controllo
anti-SQL di referto.test.ts, che cerca \bSELECT\b senza distinguere l'HTML).

Refs #2
2026-09-26 13:05:38 +00:00
Adriano 8ee2c8a2b4 longevity: layout e stile del gestionale — barra laterale, tabelle, bottone d'azione
Variante "gestionale" di Longevity.astro (finora dichiarata e non implementata):
barra laterale sul modello di StressIndex.astro, con le voci Clienti, Nuovo cliente
e un link "Il mio referto". CSS nuovo in longevity.css, solo classi lg-* e solo i
colori già ammessi (nessun colore letterale nuovo: il bianco del testo sul bottone
pieno riusa var(--lg-surface), non un #fff aggiuntivo).

Refs #2
2026-09-26 13:05:32 +00:00
Adriano 5dc6b85d52 longevity: pagina /longevity/progressione, grafico e tabella dell'andamento
Progressione.tsx: una linea per macro-score (connectNulls=false: un dato
mancante è un buco, non una linea inventata), tratteggio e spessore a
distinguere le serie invece di un colore nuovo, riga verticale tratteggiata
sui cambi di versione; sotto, la tabella per asse. Colori identici a
Radar.tsx, nessuno nuovo.

progressione.astro: stesso modello di io.astro (codicePerUtenteLoggato,
nessuna query diretta); sotto i due punti mostra il messaggio che ne serve
almeno uno in più. io.astro linka la pagina nuova.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:57 +00:00
Adriano 4cd911098d longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo
Una sessione per punto (solo quelle con righe in score), i punteggi congelati
da leggiScore, e cambioVersione quando model_version o quest_version (non
nullo) cambiano rispetto al punto precedente — un check-up in mezzo, con
quest_version NULL, non innesca né nasconde un cambio.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:50 +00:00
Adriano 922ab40eb2 longevity: verificaMisura, misureDaVerificare, endpoint di verifica dal gestionale
misure.ts: misureDaVerificare(db, clientCode) legge le misure fuori range mai
verificate; verificaMisura(db, {misuraId, esito, operatore}) conferma o scarta.
La conferma ricalcola e ricongela lo score (salvaScore + calcolaSessione) per
la sessione della misura e ogni sessione successiva dello stesso cliente, che
puo' averla ereditata via misureValideAlla; lo scarto non tocca score. Rifiuta
misura inesistente, gia' nel range, o gia' verificata.

pages/api/longevity/gestionale/verifica.ts: POST da form (misura_id, esito),
operatore dalla sessione (locals.user.username). Il client_code del redirect
si legge dalla misura nel database, mai dal form.

tests/longevity/verifica-misure.test.ts: migrazione (ALTER TABLE su un
longevity.db vecchio, idempotente), conferma che ricalcola la sessione e le
successive con sessioniRicalcolate, scarto che non scrive in score, i rifiuti
(doppia verifica, misura nel range, id inesistente), canAccessPath per
cliente/trainer sulla nuova rotta.

Suite: 219 passed (212 + 7 nuovi). tsc --noEmit: 0 errori.

Refs #7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:16 +00:00
Adriano 77829b2d12 longevity: crea l'accesso al sito per un cliente che ha già il fascicolo
accessoDi/collegaAccesso in anagrafica.ts (e le varianti sul singleton
accessoDelCliente/collegaAccessoCliente) leggono e scrivono il legame
clienti.user_id restando l'unico punto che apre identity. accesso.ts
aggiunge creaAccessoCliente: crea l'utente cliente, lo collega, e se il
collegamento fallisce cancella l'utente appena creato (compensazione,
due database, stesso schema di creaCliente). Nuova pagina del
gestionale pages/longevity/gestionale/cliente/[code]/accesso.astro:
genera username e password una volta sola, niente redirect, no-store.

Refs #8
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:00:48 +00:00
Adriano f096807ed6 longevity: schema misure con verifica/verificata_da/verificata_at + migrazione
Aggiunge le tre colonne allo schema (db.ts) e una funzione migra() idempotente
che le aggiunge via ALTER TABLE a un longevity.db esistente (PRAGMA table_info
per capire cosa manca): CREATE TABLE IF NOT EXISTS non tocca una tabella gia'
creata, e in produzione un longevity.db esiste dal 03/09/2026.

misureValideAlla (motore/index.ts) lascia entrare anche le misure fuori range
confermate (verifica = 'confermata'), non solo quelle nel range.

Refs #7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:58:25 +00:00
Adriano 95ffa08197 longevity: API del gestionale (clienti, check-up) e test dell'unità 2a
pages/api/longevity/gestionale/clienti.ts: POST del nuovo fascicolo (nome,
cognome, sesso obbligatori; data di nascita/email/telefono facoltativi) →
apriFascicolo → 303 alla scheda cliente, o 303 al modulo con ?errore=.
pages/api/longevity/gestionale/checkup.ts: POST del check-up manuale, con
misureDalForm (funzione pura, esportata e testata) per il parsing del form —
virgola decimale accettata, campo vuoto = non misurato, valore non numerico
ferma tutto, chiavi che non sono campi f_* attivi ignorate. operatore sempre
da locals.user (mai dal corpo, stesso principio del client_code in
questionario.ts); eta da etaDelCliente. Entrambi riservati a admin+trainer da
RULES in auth.ts (già in vigore, verificato dal test su canAccessPath).

tests/longevity/gestionale.test.ts: 17 test — salvaCheckup e il congelamento
dei punteggi, il referto che combina check-up e questionario di sessioni
diverse (#1), i rifiuti prima della scrittura, le misure fuori range, le
versioni parametrizzate dell'anagrafica, apriFascicolo sul singleton (stesso
trucco env-var di questionario-pagina.test.ts), misureDalForm, canAccessPath.

vitest: 229 passed (212 di partenza + 17). tsc --noEmit: 0 errori.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:53:46 +00:00
Adriano 758463bd26 longevity: gestionale.ts — check-up manuale, sessioni e misure fuori range
campiDelCheckup raggruppa i test fisici attivi per asse nell'ordine dei pesi
(letto dal dato, come contenitoriDi in vista.ts). salvaCheckup segue lo stesso
schema di salvaDalForm: valida tutto (misure presenti, solo test f_*, cliente
esistente) prima di aprire qualunque cosa, poi un'unica transazione — apertura
sessione tipo 'checkup', registraMisure fonte 'manuale', calcolaSessione +
salvaScore — perché il congelamento dei punteggi è parte del salvataggio, non
un passo lasciato a chi legge dopo. sessioniDi e misureFuoriRange sono le due
letture per la scheda cliente del gestionale.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:53:35 +00:00
Adriano 8e0b9e4647 longevity: anagrafica del gestionale — elenco, dettaglio, età, apertura fascicolo
elencoClienti/clienteDaCodice/etaDelCliente/apriFascicolo (versione operativa,
sul singleton) affiancate alle rispettive versioni parametrizzate
(elencoClientiParam/clienteDaCodiceParam/etaDelClienteParam), sullo stesso
schema di codicePerUtente/codicePerUtenteLoggato: la parametrizzata resta
testabile su un DB :memory:, l'operativa è quella che pagine ed endpoint del
gestionale importano. apriFascicolo apre sempre senza user_id — l'accesso è #8,
non questa unità — per costruzione del tipo di `dati`, non per convenzione.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:53:27 +00:00
Adriano af8afebba8 longevity: misureValideAlla, spareggio sulla riga e nota sulla conferma dei fuori range
Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:40:31 +00:00
Adriano 4968b75472 longevity: collega i test fisici a calcolaSessione, misure valide fra sessioni
Un check-up e un questionario sono sessioni separate: prima di questo
commit calcolaSessione leggeva solo le misure della sessione corrente,
quindi l'una non vedeva mai i dati dell'altra. misureValideAlla (nuova,
motore/index.ts) legge invece, per ogni test_id, la misura valida piu'
recente del cliente fra tutte le sue sessioni fino a quella data.
sottoDominiDellaSessione unisce sotto-domini da curva (questionario) e
sotto-domini fisici (sottoDominiFisici) in un'unica mappa, usata sia da
calcolaSessione (livello 1) sia da vista.ts (sottoDominiPresenti, per
"cosa manca" nel referto — non piu' una query separata sulla tabella
misure). Aggiornati i commenti di seed-demo.ts (il registro ora contiene
anche le voci f_*, anche se questa demo non le compila).

Test nuovi in motore-fisici-sessione.test.ts: il caso demo() dell'oracolo
end-to-end, un ingresso mancante o l'eta assente che lascia il
sotto-dominio fuori, la combinazione fra sessioni (check-up dopo il
questionario, sessione precedente che non vede il futuro, misura fuori
range sostituita dalla precedente valida). 202 test esistenti restano
verdi, 9 nuovi: 211 totali. tsc --noEmit: 0 errori (0 anche su 13d8cdd).

Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:38:34 +00:00
Adriano 48910d84ba longevity: registro dei test fisici e sottoDominiFisici (porting dell'oracolo)
Aggiunge le 24 voci f_* al registro (misure grezze del check-up, senza
curva/sotto_dominio: il calcolo non passa da applicaCurva) e
motore/fisici.ts (sottoDominiFisici), che chiama le funzioni gia'
verificate di test-fisici.ts con eta e sesso per produrre i punteggi di
sotto-dominio. Un ingresso mancante lascia il sotto-dominio assente, mai
un ripiego. Non ancora collegato a calcolaSessione (prossimo commit).

Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:38:01 +00:00
Adriano Dal Pastro 13d8cdd137 Il sito lascia le piattaforme: separazione fusa in main
Fuso `feat/split-apps` (fermo dal 03/09 e mai portato in main, mentre il container
delle piattaforme girava gia' da li'): il sito e le piattaforme erano online in due
copie, con database diversi e ruoli divergenti — `nicola` era `admin` di la' e
`piattaforme` di qua, e la promozione di ieri valeva solo su una delle due.

Adesso `insanitylab.it/campus`, `/piattaforme` e `/login` fanno 404: le aree
riservate stanno solo su `piattaforme.tielogic.xyz`. Il pannello `/admin` resta.
Nel database del sito restano `admin` ed `editor`; `nicola` e `adriano` sono usciti.

Il lavoro di oggi e' passato intatto: edizione `settembre-2026` coi cinque
pacchetti, i 73 tag, il countdown al 19/09 e la correzione del sabato. Verificato
sull'immagine nuova prima di metterla, chiedendo la pagina a quattro date.

Tre cose che lo split del 03/09 aveva lasciato indietro, trovate eseguendo:

- **`Dockerfile` del sito copiava ancora `campus-content`**, spostato sotto
  `apps/platforms`: il build falliva. Nel sito non c'era piu' un solo riferimento a
  quella cartella - solo la riga del Dockerfile. Rimossa.
- **`migra-utenti.mjs --togli-dal-sito` era un no-op silenzioso**: il `continue`
  sugli utenti gia' presenti a destinazione saltava anche la cancellazione
  dall'origine. Siccome copiare e ripulire sono due passi separati apposta, l'opzione
  non funzionava proprio nel caso per cui esiste. Ora chi e' gia' di la' esce di qua
  lo stesso, e il riepilogo dice quanti ne ha tolti.
- **`.env` non e' in git**, quindi il merge non lo ha spostato sotto `apps/sito/`.
  L'`up` e' fallito dopo che il `down` aveva gia' tolto il container: **9,7 secondi
  di sito giu'**, misurati. La prova con `docker run` non lo aveva visto perche' non
  legge `env_file`. Il rimedio - `docker compose config` prima dello swap - e' scritto
  nel README del sito, che prima non diceva come si fa un deploy.

Sito 149 test verdi, piattaforme 202.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 10:18:00 +00:00
Adriano Dal Pastro 33091be802 Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts:
#	apps/sito/compose.yaml
2026-09-04 10:09:29 +00:00
Adriano Dal Pastro faf7647a5f La promo di settembre entra in produzione prima della mezzanotte
Gli Opening Bundles scadono stasera alle 23:59:59 e dopo di loro l'elenco delle
edizioni era vuoto: senza questo merge, dal 5 mattina la pagina avrebbe mostrato
"Nessuna promozione in corso" con le promo del cliente vive.

Merge autorizzato da Adriano il 04/09/2026 dopo aver visto l'anteprima.

Lo scambio a mezzanotte non richiede altri interventi: la pagina legge l'edizione
valida nell'istante della richiesta, e il nuovo container va in produzione adesso
con dentro entrambe le edizioni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 09:35:14 +00:00
Adriano Dal Pastro 2b71cbb49e L'inaugurazione e' di sabato, e la fascia morta fra 700 e 701 px
Due rilievi della revisione, uno dei quali fuori dalla promo.

**`eventi.astro`: "venerdi' 19 settembre 2026"**, ma il 19/09/2026 e' **sabato**
(`calendari.ts:136` lo diceva gia' giusto). E' la meta description della pagina
dell'inaugurazione: e' cio' che si legge nell'anteprima di un link su WhatsApp,
che e' il canale con cui questo cliente parla ai suoi. Una riga, ma pubblica e
falsa.

**`.cards-5` fra 700 e 701 px non era coperta da nessuna regola**: la fascia
intermedia aveva `and (min-width: 701px)`, e a larghezze frazionarie - uno zoom
del browser dà 700,5 px - non valeva ne' quella ne' il blocco <=700, quindi le
cinque card tornavano a sei colonne dentro ~570 px. Tolto il `min-width`: ci
pensa il blocco <=700, che viene dopo e a pari specificita' vince.

Tolta anche la riga ridondante `.cards-5 { 1fr }` nel blocco <=700: `.lp .cards`
copre gia' tutte le griglie a una colonna, ed e' lo stesso meccanismo per cui
`.cards-3` funziona senza una regola sua.

Restano non toccati, perche' verificati corretti: la virgola di "composizione
corporea, e analisi HRV" e' **nella creativa** (a fine riga, nel layout su tre
righe), e le code esplicative delle card 1 e 2 vengono dai testi di agosto
gia' approvati dal cliente, non dalle creative compresse per il formato social.

186 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 06:53:30 +00:00
Adriano Dal Pastro f914d0699a La promo di settembre subentra ai bundle il 5, cinque pacchetti
Gli Opening Bundles scadono il 04/09 alle 23:59:59 e dopo di loro non c'era
nessuna edizione: dal 5 il sito avrebbe mostrato "Nessuna promozione in corso"
mentre le promo erano vive. Adriano ha consegnato le sei creative il 03/09.

Edizione `settembre-2026` (5 -> 19 settembre, chiude il giorno
dell'inaugurazione): tornano i pacchetti da 5 incontri, con in piu' il
Check-up avanzato, che e' una valutazione singola e per questo dice
"1 valutazione" invece di "5 incontri". Prezzi 250/150/130/80/70.

Due cose che la struttura non prevedeva:

- **Nessun prezzo barrato.** Le creative danno il solo prezzo promo, e i
  listini pieni di agosto (300/220/140/100) sono numeri che nessuno ha
  riconfermato - riportarli accanto ai nuovi sarebbe stato inventarli.
  `price-old` diventa opzionale nella CardSpec (`priceOld`) invece di essere
  un tag vuoto: cosi' la spec dichiara che quella card non ne ha, e il test
  lo pretende solo dove c'e'. Agosto e i bundle sono marcati `priceOld: true`,
  quindi restano sotto controllo come prima.

- **Cinque card non stanno in una griglia da due o da tre** senza lasciare un
  buco in fondo. `.cards-5` va a sei colonne con span due, cosi' le ultime due
  si spostano al centro; a due card per riga sotto i 1000px, a una sotto i 700.

Testi ripresi dall'edizione di agosto, gia' approvata dal cliente, cambiando
solo cio' che le date rendevano falso: la FAQ delle class diceva "gli orari
saranno pubblicati entro Settembre" e adesso e' settembre, quindi rimanda al
calendario che esiste. La FAQ 7 sul Check-up e' nuova e va fatta leggere a
Nicola prima della pubblicazione.

186 test verdi, `astro check` invariato (i 3 errori della pagina promo sono
preesistenti e non toccati).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPKFABRT3y26dFNMquF3CS
2026-09-04 06:43:50 +00:00
AdrianoDev a59b4e0c0e crea-fascicolo: aprire il fascicolo di chi ha gia' un account
"Anche un admin deve avere il suo referto" (Adriano). Il codice lo permetteva
gia': il referto si aggancia all'utente, non al ruolo. Mancava il modo di
creare la riga che lega utente e fascicolo senza passare da seed-demo, che
inventa anche la persona e le risposte.

Non indovina niente: --sesso e' obbligatorio perche' entra nelle curve dei
punteggi, la data di nascita si puo' omettere e allora l'eta' resta vuota -
meglio un campo vuoto di una data plausibile e falsa. Si ferma se quell'utente
ha gia' un fascicolo: due fascicoli per la stessa persona sono due storie
parallele, e nessuna e' quella giusta.

Il fascicolo nasce vuoto: il referto dice "non hai ancora compilato il
questionario" finche' la persona non lo compila, che e' il percorso vero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 13:02:54 +00:00
AdrianoDev c8a6b318cb Dopo il login si atterra sulla scelta della piattaforma
Chiesto da Adriano: si entra e si sceglie dove andare, anche quando la scelta
e' una sola. Prima ogni ruolo saltava dritto nella sua sezione.

Non basta cambiare landingFor: /piattaforme era riservato ai ruoli admin e
piattaforme, quindi mandarci un cliente avrebbe prodotto un CICLO di redirect
- il middleware nega la pagina, rimanda a landingFor, landingFor rimanda dove
il middleware nega. L'indice diventa la casa comune (aperto a ogni ruolo),
mentre Stress Index, che sta sotto lo stesso percorso, resta riservato: la sua
regola viene prima, perche' vince la prima che combacia.

L'indice mostra a ognuno solo le sezioni che puo' aprire, quindi la scelta non
espone mai una porta chiusa.

Un test nuovo presidia l'invariante che rende impossibile il ciclo: la
destinazione di landingFor deve essere accessibile a TUTTI i ruoli.

Verificato in produzione: il cliente demo atterra sulla scelta e vede la sola
card Longevity, adriano vede Campus e Stress Index, e da li' il percorso
prosegue fino al referto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:53:32 +00:00
AdrianoDev 62b19b21d9 Il link del sito all'area riservata punta all'host che esiste
Era rimasto apps.insanitylab.it, che e' l'indirizzo definitivo ma non ha
ancora un record su Aruba: alla fusione il menu del sito avrebbe portato a un
errore di DNS, e nessun test se ne sarebbe accorto perche' il link esce dal
dominio e non lo serve nessuna rotta interna.

Ora punta a piattaforme.tielogic.xyz, con un test che lo presidia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:45:17 +00:00
AdrianoDev 89d2186e69 Il vecchio sottodominio esce anche dal compose del ramo
insanitylab.tielogic.xyz e' stato tolto dal DNS: una regola Traefik che lo
nomina fa fallire una sfida ACME a ogni tentativo, ed e' cio' che Let's
Encrypt limita. Resta il redirect di www.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:41:53 +00:00
AdrianoDev f5fac89c1b Via dal router il vecchio sottodominio, tolto dal DNS
Adriano ha rimosso il record insanitylab.tielogic.xyz il 03/09/2026. Una
regola Traefik che nomina un host inesistente non e' inerte: il router chiede
un certificato per i nomi della sua regola, e la sfida ACME su un nome che
non risolve fallisce a ogni tentativo - proprio cio' che Let's Encrypt limita.

Resta il redirect di www verso l'apex, che e' l'unico ancora vero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:41:52 +00:00
AdrianoDev 15dbe6a397 Le aree riservate hanno il loro host: piattaforme.tielogic.xyz
Creato da Adriano sulla zona tielogic.xyz (Hostinger). Sostituisce il prestito
di insanitylab.tielogic.xyz, durato un'ora: quell'host e' tornato al suo 308
verso l'apex e il sito e' di nuovo esattamente com'era.

allowedDomains, router del container e router di redirect del sito cambiano
insieme: e' la terna che decide di chi e' un host, e sfasarla lascia un
indirizzo senza padrone o un login che risponde 403.

Verificato in HTTPS: certificato emesso per il nome nuovo, login e referto
funzionanti, insanitylab.it 200 e i due redirect al loro posto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:39:29 +00:00
AdrianoDev 665c598462 Il vecchio sottodominio torna al suo redirect
Adriano ha creato piattaforme.tielogic.xyz, che e' il nome giusto per le aree
riservate: il prestito di insanitylab.tielogic.xyz e' durato un'ora e finisce
qui. Il sito torna esattamente come stamattina - apex, www e vecchio
sottodominio - e le piattaforme stanno su un host loro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:39:29 +00:00
AdrianoDev a7b81e2ff4 Le aree riservate partono da insanitylab.tielogic.xyz
Deciso da Adriano il 03/09/2026: il dominio del cliente resta su Aruba e non
si tocca, le piattaforme partono da un sottodominio nostro. Scelto l'host che
risolveva gia' e aveva gia' il certificato - fino a oggi faceva 308 verso
l'apex del sito - invece di aspettare un record DNS nuovo.

Tre modifiche coordinate, che vanno insieme o l'host resta senza padrone:
- allowedDomains dell'app: aggiunto l'host temporaneo accanto a quello
  definitivo. Senza, dietro Traefik il CSRF di Astro respinge ogni POST con
  403 e il login sembra rifiutare le credenziali.
- router del container: Host(insanitylab.tielogic.xyz)
- compose del sito: quell'host esce dal router di redirect (www continua)

Quando il record A "apps" esistera' su Aruba si tengono doppi per un po',
invece di spostare di netto un indirizzo che qualcuno ha gia' salvato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:36:33 +00:00
AdrianoDev 4103376eb1 Il vecchio sottodominio esce dal redirect: serve alle aree riservate
insanitylab.tielogic.xyz faceva 308 verso l'apex dal 12/07. Dal 03/09/2026
ospita le aree riservate (Campus, Stress Index, Longevity) mentre si aspetta
il record A di apps.insanitylab.it su Aruba: il dominio del cliente resta
dov'e', le piattaforme partono da un sottodominio nostro.

www.insanitylab.it continua a reindirizzare come prima.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:34:59 +00:00
AdrianoDev 18b0877026 Longevity: il registro si semina da solo, e un cliente demo con un referto vero
seedRegistro/seedPesi esistevano dal 22/08 ed erano chiamati SOLO dai test: su
un database nuovo il registro restava vuoto, campiDelQuestionario non trovava
niente e la pagina del questionario si apriva SENZA DOMANDE. Non era un problema
della demo, era il primo avvio in produzione - lo stesso difetto gia' visto in
questo ramo (una funzione corretta che nessuno chiama), con la stessa
correzione: agganciarla dove non si puo' dimenticare, cioe' alla creazione del
database. Entrambe le semine sono INSERT OR REPLACE, quindi rilanciarle e' a
vuoto, e i punteggi gia' emessi non cambiano (sono congelati nella tabella
"score", che e' quello che il referto legge). Quattro test lo presidiano.

scripts/seed-demo.ts crea l'utente con ruolo "cliente", il fascicolo (passando
da creaCliente, l'unico varco fra identity e longevity) e compila il
questionario attraverso salvaDalForm - lo stesso percorso della pagina vera,
consenso compreso - cosi' i punteggi vengono calcolati e congelati come in
produzione. Si ferma se l'utente esiste gia': rifare la demo su dati esistenti
vuol dire duplicare o cancellare un fascicolo, e non e' una decisione da script.

Verificato girando l'app: Energy 74,5/100 con copertura 50%, Performance e
Recovery dichiarati insufficienti (5% e 35%) con l'elenco di cosa manca, Fitness
Age assente. E' la regola di Nicola all'opera - annullare invece di stimare - ed
e' lo stato vero del prodotto: nel registro ci sono solo le 20 domande del
questionario, i test fisici arrivano col gestionale trainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 12:27:48 +00:00
AdrianoDev 6301f521fd 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
2026-09-03 11:21:12 +00:00
AdrianoDev 510d7ca4ec Struttura: il sito esistente si sposta sotto apps/sito
Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09:
due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna
resti un progetto Astro completo ed estraibile.

Qui non cambia niente del codice: solo la radice. I pattern del .gitignore
che avevano una barra erano ancorati alla radice e dopo lo spostamento non
avrebbero piu' coperto niente (data/*.db*, uploads/*): resi validi a
qualunque profondita'.

Suite: 45 file, 347 test verdi prima e dopo lo spostamento.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 11:05:54 +00:00
AdrianoDev c03edccd8e Merge remote-tracking branch 'origin/main' into feat/split-apps
# Conflicts:
#	.gitignore
2026-09-03 11:03:09 +00:00
Adriano Dal Pastro b75e12de86 Calendario: alzato il min-width a 780px, che il layout fisso rende necessario
Coda della modifica precedente, e senza questa quella era una regressione sul
telefono. A colonne uguali la piu' stretta deve contenere la parola piu'
lunga; in layout automatico il browser allargava da solo le colonne piene e
stringeva quelle vuote, quindi il problema non si vedeva.

Il conto, misurato sul Montserrat 400 che il sito serve davvero (non
stimato): REFORMER a .7rem con letter-spacing .1em e' 75,4 px, mentre la
colonna a 720px ne offriva 72,7 di spazio utile — 2,7 in meno. La parola
sarebbe uscita dalla cella proprio dove il minimo si vede, cioe' sul
telefono, che quella tabella la fa scorrere in orizzontale apposta.

A 780px la colonna da' 81,3 px, ~6 di margine. In stampa il conto era gia'
buono (corpo .62rem e padding ridotto: 66,8 px in 77,9).

Il conto e' scritto nel CSS accanto alla regola: chi tocca padding, corpo o
spaziatura deve rifarlo.

185 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:57:41 +00:00
Adriano Dal Pastro 718ca499c4 Calendario: le sette colonne dei giorni tutte della stessa larghezza
Chiesto da Adriano. La tabella era in layout automatico, quindi il browser
dimensionava ogni colonna sul contenuto: il sabato, che porta la nota «solo
il 12 settembre», veniva largo quasi il doppio, e i giorni senza class si
stringevano. Una griglia oraria con le colonne di misure diverse si legge
male proprio dove serve — nell'incrocio fra un giorno e un'ora.

`table-layout: fixed` piu' una larghezza sulla sola colonna delle ore
(64px): tutto il resto viene diviso in parti uguali fra le sette dei giorni.

E' lo stesso criterio che il PDF applica da sempre — la' le colonne sono
`(larghezzaUtile - larghezzaOra) / GIORNI.length` — quindi adesso le due
versioni dello stesso foglio si somigliano invece di divergere.

La colonna delle ore resta piu' stretta dei giorni, come nel PDF: ci sta
dentro «12.15» e nient'altro.

185 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:55:14 +00:00
Adriano Dal Pastro 4f031cf571 Calendario: niente class il venerdi' dall'1 al 18 settembre
Chiesto da Adriano l'01/09. Tolte le tre celle del venerdi' dal calendario
'settembre-1-18': MOBILITY alle 8.30, REFORMER alle 9.15 e alle 10.00.

Erano tutte di mattina, quindi in quel periodo il venerdi' resta una colonna
completamente vuota. Resta VUOTA e non barrata: togliere le class non vuol
dire che la sala sia chiusa, e nessuno l'ha detto — il dato distingue le due
cose apposta (`chiusi`), e sceglierne una che non ci e' stata comunicata
sarebbe inventare un'informazione.

⚠️ L'edit e' confinato al primo calendario. Le tre righe del venerdi' sono
IDENTICHE, carattere per carattere, in 'settembre-21-in-poi' — dove il
venerdi' deve restare — quindi una sostituzione sul file intero ne avrebbe
prese sei invece di tre. Verificato dopo: dal 21 settembre il venerdi' ha
ancora PILATES 7.45, MOBILITY 8.30, REFORMER 9.15 e 10.00.

Aggiunto un commento nel dato, perche' confrontando le due griglie il buco
sembra una trascrizione monca e il prossimo che passa lo "corregge".

185 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:51:13 +00:00
Adriano 6ba8794770 Fuori dal versionamento la cache di Python
Nel repo ora c'e' anche Python — lo script che rigenera la locandina, e i
riferimenti dei test longevity — e ogni esecuzione lascia dietro __pycache__.
Comparivano come file non tracciati a ogni git status, senza essere codice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:16:29 +02:00
Adriano Dal Pastro 0ea1cb5e77 Eventi: la citazione di chiusura fra virgolette e piu' grande
Caporali «» attorno alla frase — sono le virgolette italiane, e su un corsivo
di quel corpo reggono meglio delle doppie curve.

Corpo da clamp(1.05rem, 2.4vw, 1.3rem) a clamp(1.35rem, 3.4vw, 1.9rem): ~+45%
sia sul minimo che sul massimo, quindi cresce anche su mobile. Interlinea da
1.8 a 1.5, altrimenti a quel corpo le due righe galleggiavano.

Il <br /> che spezza la frase in due versi resta dov'era.
2026-08-28 11:12:33 +00:00
Adriano 7f96b95e04 Eventi: via il piano dai blocchi, la locandina scende in fondo
Tre ritocchi chiesti dal cliente: il "Primo piano" sotto "Prova i nostri
servizi" spariva il messaggio piu' che aiutare, il download della locandina
sta meglio a fine lettura che nell'apertura, e il titolo del programma va
a capo dopo "Come si svolge".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 11:55:14 +02:00
Adriano 13d73f90a8 Eventi: i test seguono la data separata e l'URL stampato nel QR
Il test dell'inaugurazione cercava la data dentro il titolo, ma i due sono
stati separati in tag distinti il 19 agosto: era rosso da allora, anche in
produzione.

Il nuovo test tiene insieme le tre cose che devono restare d'accordo, perche'
il QR delle locandine affisse non si corregge piu': la rotta servita dal sito,
l'URL cucito nel codice e il PDF che la pagina offre in download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 11:43:13 +02:00
Adriano 7fd3961469 Eventi: la landing dell'In-Sanity Hour, con la locandina da scaricare
La pagina dell'inaugurazione del 19 settembre: il programma completo della
giornata, gli slot di prova del mattino e del pomeriggio, la festa serale.

Il blocco del titolo è il ritaglio della locandina vera, non una sua
riproduzione: i caratteri originali non li abbiamo, e ricostruirli a occhio
è già stato provato senza riuscirci. Il resto della pagina usa la tipografia
del sito e i colori campionati dalla locandina, così volantino e schermo si
riconoscono come la stessa cosa.

La locandina scaricabile è ricalcata in vettoriale dallo stesso JPEG — tre
colori piatti, quindi ogni forma diventa un tracciato — e monta il QR code
definitivo verso questa pagina, un filo più piccolo dell'originale per
lasciargli attorno il margine muto che lo standard richiede.

I testi non passano più dal pannello contenuti: la pagina è in codice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 11:41:47 +02:00
Adriano 1d6bdd0b3d longevity: revisione finale — il motore ora si calcola, il consenso è atomico, il codice cliente è tipato fuori dal corpo
Sei correzioni dalla revisione finale del 22/08, ognuna con test scritto prima:

1. salvaDalForm calcolava mai i punteggi (calcolaSessione/salvaScore) dopo il
   salvataggio: un cliente compilava venti domande e vedeva un referto vuoto.
   Ora avviene nella stessa transazione del salvataggio.
2. Il corpo della richiesta dell'endpoint questionario è tipato
   (CorpoQuestionario): client_code non esiste in quel tipo, non è più
   prendibile per errore. Il vecchio test a lista di nomi vietati era vacuo
   (cercava body./corpo., il file usa data.) — sostituito con un test
   comportamentale end-to-end sull'endpoint vero.
3. Il consenso: aggiunto il test sul ramo affermativo (non coperto, vacuo
   quanto il punto 2); la nota di consenso ora è nella stessa transazione dei
   dati sanitari; ha una versione propria (CONSENT_VERSION) invece di quella
   del questionario.
4. La prima apertura del referto ora spiega invece di sembrare rotta: Fitness
   Age con nota quando manca, spiegazione del radar prima del grafico,
   copertura sempre visibile, invito a fare i test in sala col trainer,
   numeri formattati all'italiana.
5. Aggiunto il link per ricompilare il questionario nella pagina del referto
   (prima spariva appena esisteva una compilazione).
6. Il test anti-query ora scandisce tutte le pagine sotto src/pages/longevity/,
   non due nomi a mano. Il test anti-giunzione identity/longevity resta con lo
   stesso limite di sempre (createIdentityDb è esportata per creaCliente) —
   corretto solo il commento perché lo dichiari onestamente.

Report con comandi e output letterali:
.superpowers/sdd/2026-08-22-longevity-interfaccia/review-finale-report.md
2026-08-22 15:17:47 +02:00
Adriano a56398ea02 longevity: il controllo sui colori guarda tutto il foglio, non solo lg-score
Filtrava le regole sul selettore ("solo quelle con lg-score nel nome"): un elenco
di quali regole guardare invecchia appena nasce una classe nuova, ed e' successo
subito con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3), mai
controllate perche' non contengono quella stringa. Tolto il filtro sul selettore:
ora si scandisce ogni regola del foglio (che e' interamente prefissato lg- per
costruzione), con un elenco di eccezioni dichiarate (selettore + valore + motivo)
per gli usi legittimi di un colore fuori lista (il #fff del testo sul bottone
pieno d'accento). Verificato rosso con un colore-giudizio iniettato sia su una
classe del radar sia su una del macro-score, verde ripristinato in entrambi i casi.
2026-08-22 14:43:57 +02:00
Adriano 6630be7a02 longevity: la prova sul congelamento ora discrimina davvero il ricalcolo
Il peso mutato prima era l'unico sotto-dominio del suo asse (questionario_lifestyle,
1.00 in Stile di Vita & Sonno): in una media pesata a un solo elemento disponibile
il peso si semplifica sia al numeratore sia al denominatore, quindi il test restava
verde anche se refertoDi avesse ricalcolato invece di leggere gli score congelati.
Sostituita con il peso di un sotto-dominio ASSENTE in un asse a piu' elementi con
copertura parziale non nulla (hrv in Recupero & Sistema Nervoso): li' un ricalcolo
sposta davvero la copertura oltre soglia. Verificato rosso col ricalcolo forzato,
verde ripristinato.
2026-08-22 14:32:43 +02:00
Adriano 7f01ee0b45 longevity: il referto del cliente, e il radar che dice cosa non sa 2026-08-22 14:28:29 +02:00
Adriano 8c980750c0 longevity: la connessione a identity non e' piu' raggiungibile fuori da anagrafica.ts
Il revisore ha dimostrato che il test anti-giunzione si aggira senza malizia:
tre file (uno riesporta la connessione a identity, uno quella a longevity, un
terzo le importa da entrambi e le usa insieme) e nessuno dei tre contiene
singolarmente le due parole cercate. Un controllo testuale file-per-file non
puo' vedere una giunzione che attraversa piu' file - quindi la risposta non
e' un test piu' furbo, e' rendere la cosa impossibile invece di sorvegliarla.

- db.ts: rimossa l'esportazione di getIdentityDb. Resta solo getLongevityDb
  (il database senza nomi, puo' usarlo chiunque) e createIdentityDb (serve ai
  test per aprire database in memoria - li' la giunzione e' voluta ed
  evidente, il problema era il singleton condiviso, non la fabbrica).
- anagrafica.ts: getIdentityDb torna dentro, privata (non esportata) e usata
  solo da codicePerUtenteLoggato. Verificato che il bypass a tre file del
  revisore ora fallisce gia' a tsc --noEmit ('has no exported member named
  getIdentityDb'), non solo a un test che lo sorveglia.
- tests/longevity/anagrafica.test.ts: il test anti-giunzione resta come rete
  secondaria, con un commento che dichiara onestamente cosa non vede (una
  giunzione via re-export su piu' file) invece di promettere una garanzia che
  non mantiene.

Due correzioni minori dalla stessa revisione:
- api/longevity/questionario.ts: salvaDalForm (via salvaCompilazione) lancia
  se una risposta porta un test_id sconosciuto al registro; ora e' avvolta in
  try/catch e risponde 400 pulito invece di un 500 generico, come gia' fa
  src/pages/api/contact.ts per l'invio email.
- components/longevity/Questionario.tsx: un campo con tipo_valore='txt' nel
  registro ora rende un input testuale (non piu' sempre "number"), e in
  invio i campi testuali non vengono convertiti in NaN e scartati in
  silenzio - restano fuori esplicitamente, con un commento che dice perche'.
2026-08-22 14:12:43 +02:00
Adriano f0629db3c4 longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale
L'endpoint /api/longevity/questionario apriva anche identity (getIdentityDb)
per risolvere il client_code, violando la garanzia della spec (§4): un solo
modulo, anagrafica.ts, tiene insieme le due connessioni. La logica di
giunzione era gia' al posto giusto (chiamava codicePerUtente, che sta in
anagrafica.ts) ma l'APERTURA delle connessioni no.

- anagrafica.ts: nuova codicePerUtenteLoggato(userId), apre lei stessa
  identity via getIdentityDb() (e' il modulo autorizzato) e delega a
  codicePerUtente. L'endpoint chiama solo questa: apre esclusivamente
  longevity, per salvare.
- tests/longevity/anagrafica.test.ts: il test anti-giunzione cercava due nomi
  letterali (createIdentityDb/createLongevityDb) e non vedeva getIdentityDb/
  getLongevityDb, nati dopo di lui - la stessa lezione del semaforo nel Task 1
  (proteggere il nome invece della cosa). Ora e' strutturale: segnala
  qualunque file (esclusi anagrafica.ts e db.ts) che contenga sia "Identity"
  sia "Longevity", in qualunque forma. Verificato con un file finto sotto
  src/ che apriva entrambe con i nomi nuovi: il test diventa rosso, poi
  torna verde dopo la cancellazione.
2026-08-22 14:01:33 +02:00
Adriano f5ca841d92 longevity: il questionario in pagina, e il consenso che si puo negare davvero
Sostituisce il prototipo del cliente (salvava anche con "No" al consenso
sanitario, perche' controllava solo che il campo non fosse vuoto). Qui il
consenso si verifica PRIMA di scrivere qualunque dato: se non e' affermativo
non si apre nessuna sessione, e la risposta lo dice. Il rifiuto si registra
comunque fra le note di profilo, con esito e versione del questionario: va
dimostrato tanto quanto un assenso.

- src/lib/longevity/vista.ts: campiDelQuestionario (dal registro, non da una
  lista nel codice) e salvaDalForm (consenso prima di tutto, poi delega a
  salvaCompilazione).
- src/pages/api/longevity/questionario.ts: il client_code arriva SEMPRE da
  codicePerUtente sulla sessione, mai dal corpo della richiesta.
- src/lib/longevity/db.ts: getLongevityDb/getIdentityDb, connessioni singole
  per processo (come getDb() del sito), cosi' l'endpoint non apre una nuova
  connessione a ogni richiesta.
- src/components/longevity/Questionario.tsx + pages/longevity/questionario.astro:
  isola React a quattro blocchi (le tre aree di domande + note/consenso), la
  pagina non interroga mai il database da sola.
2026-08-22 13:55:16 +02:00
Adriano 2ea822a84d longevity: test anti-semaforo strutturale (colori ammessi, non nomi vietati) e rotta verificata sul contenuto 2026-08-22 13:43:09 +02:00
Adriano 34c66ca438 longevity: il guscio della piattaforma, senza semaforo sui punteggi 2026-08-22 13:33:21 +02:00
Adriano 779aef32e6 docs: piano dell'interfaccia — questionario e referto del cliente 2026-08-22 13:29:54 +02:00
Adriano 0e9b081a79 spec: cosa ha scoperto la costruzione del motore 2026-08-22 11:02:26 +02:00
Adriano 42a039ae6b schema: il limite del vincolo e' la migrazione che non c'e', non SQLite 2026-08-22 11:01:45 +02:00
Adriano 82a4cd2000 test: chiude l'errore di tipi pre-esistente in middleware.test.ts
Cast di FakeContext a onRequest via 'unknown' invece che diretto: i
due tipi non si sovrappongono abbastanza per tsc (FakeContext è un
doppio minimale, non un APIContext completo). Il typecheck è il
meccanismo di sicurezza di questo motore: uno rosso in permanenza è un
cancello che nessuno guarda più.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:42 +02:00
Adriano c1c868622f test: le sei tabelle dei sollevamenti sorvegliate, e il caso misto
- motore-fisici.test.ts confrontava i tiers letti dal file di
  riferimento, non le costanti TIERS_* del codice: ventiquattro numeri
  senza custode. Ora identifica la tabella dagli argomenti
  dell'oracolo ma usa le costanti vere per calcolare, quindi una
  costante alterata non combacia più con nessun caso.
- motore-caso-reale.test.ts: aggiunto il caso end-to-end mancante, un
  asse valido (Composizione Corporea, via due righe aggiunte al
  registro) e sei insufficienti — oggi la suite copriva solo "tutto
  insufficiente".
- test-fisici.ts: commento in testa che dichiara le venti curve non
  collegate al motore (vocabolario delle curve senza nomi per loro,
  applicaCurva senza modo di ricevere età/sesso) — aggancio è lavoro
  del piano degli import, non "una modifica ai dati" come la spec
  lascia intendere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:35 +02:00
Adriano 469251366e motore: chiude i tre buchi laterali della barriera dell'insufficiente
- eta_alla_data NULL non usa più il ripiego a zero: senza età la
  Fitness Age è null, non un'età di forma negativa dichiarata valida.
  Gli assi e i macro-score restano calcolabili (non dipendono dall'età).
- applicaCurva valida i parametri richiesti da ciascuna curva prima di
  applicarla: un registro con params vuoti/incompleti dà null, mai il
  punteggio pieno che dava lerp con estremi indefiniti.
- calcolaSessione esclude le misure fuori_range=1 (§9 della spec): non
  entrano nello score finché non esiste la colonna di conferma (§12,
  punto aperto).
- leggiScore(db, sessioneId): legge la tabella score ritipata come
  Punteggio, con l'ultimo calcolo per tipo+elemento esplicito nella
  query (MAX(id)), non l'ordine naturale delle righe — salvaScore non
  sovrascrive di proposito (la storia degli score si tiene, discende
  dal congelamento della §6).
- documentata la seconda divergenza dall'oracolo, mai scritta finora:
  la Fitness Age esclude gli assi insufficienti, l'oracolo li include
  comunque. Comportamento giusto, ma cambia il numero (25,0 contro
  34,8 sullo stesso profilo parziale).
- nuovo test che prova che i pesi di due model_version diverse restano
  separati (pesiDi filtra su model_version, non li fonde).

Ogni fix verificato in TDD (test rosso prima, verde dopo) e ri-rotto a
mano per confermare che discrimina davvero (vedi report).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:25 +02:00
Adriano c4524e3a73 schema: la tabella score rifiuta un valore sotto uno stato che lo nega
CHECK ((stato='ok' AND valore NOT NULL) OR (stato='insufficiente' AND
valore NULL)): il tipo Punteggio protegge il codice, non la tabella, e
l'interfaccia leggerà 'score' direttamente. Idempotente sui database
nuovi (CREATE TABLE IF NOT EXISTS); un longevity.db già esistente NON
riceve il vincolo, SQLite non lo permette via ALTER TABLE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:12 +02:00
Adriano d22f88ad71 longevity: la prova sul profilo con una sola area misurata
Task 6 - la prova sul caso reale documentato dal cliente (dati sintetici,
nessuna persona reale). Il test smontava un difetto nel task 5: RisultatoSessione
esponeva fitnessAge come Punteggio invece che number|null (come lo produce
calcolaFitnessAge in cascata.ts), quindi un fitnessAge insufficiente non era mai
`null`. Corretto in motore/index.ts, con fitnessAgeCopertura a fianco per non
perdere l'informazione che salvaScore scrive in `score`.
2026-08-22 10:07:28 +02:00
Adriano ac8b377f84 longevity test: motivazione corretta per il caso 'test disattivato', e media verificata numericamente
Il commento del test 'un test disattivato non entra nel calcolo' diceva
che q_attivita fosse l'unica voce del sotto-dominio questionario_lifestyle:
falso, ne ha sei (q_attivita, q_alimentazione, q_sigarette, q_alcol_life,
q_luce, q_schermi). Il motivo vero per cui il test funziona e' che il
fixture ne compila solo una delle sei, quindi disattivarla svuota il
sotto-dominio. Corretto il commento a dire il motivo vero, con nota su
cosa succederebbe se il fixture cambiasse.

Aggiunto anche il test mancante sulla regola della media: q_attivita(3)=65
e q_alimentazione(6)=60, stesso sotto-dominio, verificano che il valore
dell'asse "Stile di Vita & Sonno" sia la loro media (62.5), diversa sia
dalla somma (125) sia da ciascun addendo (65, 60) — nessun test esistente
controllava il VALORE della media, solo lo stato.
2026-08-22 10:02:59 +02:00
Adriano f238984828 longevity: il motore legge curve e pesi dal registro, e congela i punteggi
Task 5: applicaCurva smista sulla curva dichiarata in registro_test (non
scritta nel codice); calcolaSessione aggrega le misure attive per
sotto-dominio (media quando piu' test contribuiscono allo stesso
sotto-dominio) e usa cascata.ts coi pesi letti da pesi (pesiDi) per i
sette assi, i tre macro-score e la Fitness Age; salvaScore congela il
risultato in score, con valore a null quando lo stato e' insufficiente.

Corretto anche un refuso nel test del brief ('un test disattivato non
entra nel calcolo'): disattivava q_ore_sonno, ma quella voce sta nel
sotto-dominio questionario_sonno (peso 0.20 su Recupero & Sistema
Nervoso, sempre sotto COPERTURA_MINIMA=0.4 finche' hrv/pressione/hrr non
hanno una voce nel registro), quindi l'asse restava insufficiente prima
e dopo e il JSON risultava identico (verificato: il test falliva con le
due stringhe uguali). Corretto usando q_attivita, l'unica voce del
sotto-dominio questionario_lifestyle che pesa 1.00 su Stile di Vita &
Sonno: disattivarla fa cadere la copertura da 1.00 a 0 e lo stato passa
da ok a insufficiente, una differenza vera.
2026-08-22 09:53:17 +02:00
Adriano 93ecc3d367 longevity: la copertura si confronta grezza, non arrotondata
Bug in aggrega(): la soglia veniva confrontata con la copertura gia'
arrotondata a due decimali, quindi 0.396 (< 0.40) passava per 'ok' perche'
arrotondava a 0.40. Confronto ora sulla copertura grezza; l'arrotondamento
resta solo sul valore esposto. Generalizzata arrotonda1 in curve.ts a un
numero qualsiasi di decimali (bit-exact, banker's rounding) e riusata qui a
due decimali, invece di tenere una seconda logica di arrotondamento nel
motore.
2026-08-22 09:39:58 +02:00
Adriano 98a633b3e1 longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente 2026-08-22 09:28:40 +02:00
Adriano f029820a14 longevity: il test delle funzioni escluse copre anche score_generic_range_local
Il test controllava solo l'assenza dell'agilita (scoreAgilita/scoreAgilityMs)
e non della seconda funzione esclusa dal brief, score_generic_range_local:
se qualcuno la reintroducesse un domani nessun test se ne accorgerebbe.
Aggiunta l'assertion con lo stesso criterio, rinominato il test perche' ora
copre entrambe le esclusioni.
2026-08-22 09:24:42 +02:00
Adriano 4d76030839 longevity: unico arrotondamento al pari nel motore, esatto sul valore binario
Sposta arrotonda1 (round-half-to-even come Python) da test-fisici.ts a
curve.ts, esportata, cosi' entrambi i moduli condividono la stessa funzione
invece di due copie che sui pareggi potrebbero divergere.

Nel farlo, la versione precedente (moltiplica per 10, tolleranza 1e-9 sul
pareggio) si e' rivelata sbagliata su un caso reale: 86.35 non e' un
pareggio nel double che lo rappresenta (vale 86.34999999999999431...,
Python arrotonda a 86.3 senza ambiguita'), ma 86.35*10 arrotonda ESATTAMENTE
a 863.5 in virgola mobile, un pareggio fasullo creato dalla moltiplicazione.
La versione naive dava 86.4. Sostituita con un confronto esatto: scompone il
double in mantissa/esponente (bit IEEE 754) e confronta con aritmetica
razionale su BigInt, senza mai passare per una moltiplicazione che possa
introdurre o cancellare un pareggio.

Aggiunti in motore-curve.test.ts i casi che dimostrano il pareggio vero
(86.25 -> 86.2, 86.75 -> 86.8) e quello fasullo (86.35 -> 86.3, non 86.4:
blocca la regressione appena descritta).
2026-08-22 09:16:31 +02:00
Adriano 6f926e3197 longevity: le curve dei test fisici, verificate contro l oracolo 2026-08-22 09:09:23 +02:00
Adriano c63bb1fe80 longevity: chiude i tre buchi nei test delle curve — ramo crescente di curvaGradini mai esercitato, test zeroVal non discriminante, cardinalita mancante su due confronti con l oracolo 2026-08-22 09:02:20 +02:00
Adriano 36a2cf3fb6 longevity: le cinque curve del questionario, verificate contro l oracolo 2026-08-22 08:51:27 +02:00
Adriano 11ecc8305b piano: il codice sta nel repo, non anche nel piano 2026-08-22 08:48:26 +02:00
Adriano 236cdce076 longevity: rigenera riferimento.json con la griglia corretta (plateau offset, 2km walk multi-punto, confini plank) 2026-08-22 08:42:29 +02:00
Adriano c5249255f0 piano: rimette i commenti della griglia al posto giusto 2026-08-22 08:39:06 +02:00
Adriano 998487ed62 piano: infittita la griglia dove non aveva capacita diagnostica 2026-08-22 08:38:54 +02:00
Adriano 7cb49c6a49 longevity: l'oracolo del motore e la griglia di riferimento 2026-08-22 08:29:53 +02:00
Adriano bc9d31e193 docs: piano del motore di calcolo, verificato contro l'oracolo del cliente 2026-08-22 08:27:51 +02:00
Adriano 59b453c67e longevity: annotate le conferme di Nicola del 22/08 (plank su due assi, soglia unica al 40%) 2026-08-22 08:02:20 +02:00
Adriano 52ff28b69d spec: i sei punti emersi costruendo lo strato dati, piu' la curva x10_inv che mancava 2026-08-21 20:34:49 +02:00
Adriano 763e5611e6 longevity: revisione finale - codice cliente non riusabile, ruoli allineati, middleware verificato
- creaCliente: il codice si deduce dal massimo fra identity E longevity (non piu' solo
  identity), cosi' un client_code cancellato dall'anagrafica non torna mai disponibile
  e non si attribuiscono le misure di un vecchio cliente a uno nuovo. Compensazione se
  la scrittura su longevity fallisce dopo quella su identity.
- ROLES unica fonte in auth.ts (Role e isRole derivati); users.astro e users/new.astro
  usano quella lista invece di array scritti a mano che dimenticavano cliente/trainer.
- Commento falso su cosa protegge il middleware, riscritto: rimanda a isProtectedPath.
- vitest.config.ts: alias per astro:middleware (stesso bersaglio della pipeline vite di
  Astro), cosi' src/middleware.ts e' finalmente importabile e testabile in isolamento -
  prima nessun test lo caricava davvero.
- export.test.ts: asserzione posizionale per colonna, non piu' solo intestazioni scritte
  a mano; intercetta un riordino della SELECT che disallinea etichette e valori.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:30:23 +02:00
Adriano e6c2ac9148 longevity: test atomicità corretto - verifica rollback anche dei campi liberi 2026-08-21 20:08:12 +02:00
Adriano 2024c32965 longevity: export pseudonimizzato per costruzione 2026-08-21 20:05:19 +02:00
Adriano 91e6487da3 longevity: atomicità compilazione questionario con transazione unica 2026-08-21 20:02:23 +02:00
Adriano f21b64fcc5 longevity: salvataggio compilazione questionario, versionata 2026-08-21 19:57:35 +02:00
Adriano d3abbf9bf7 task-5 fix: isRole, login redirect, create-user script per cliente e trainer 2026-08-21 19:53:12 +02:00
Adriano 473c0d8c36 fix: isProtectedPath nel middleware, landingFor per cliente e trainer 2026-08-21 19:44:08 +02:00
Adriano 502ccb1585 longevity: ruoli cliente e trainer con le rispettive rotte 2026-08-21 19:41:45 +02:00
Adriano b7ceece4ed test(anagrafica): allarga la scansione a tutto src/ per rilevare giunzioni non autorizzate
Rafforza il test che verifica l'isolamento tra identity e longevity.
Invece di scandire solo src/lib/longevity/, ora scandisce ricorsivamente
tutto src/ inclusi .astro e .ts, saltando node_modules e dist.

Preserva le due sole esclusioni autorizzate: anagrafica.ts (il modulo
autorizzato) e db.ts (dove le factory sono definite).

In questo modo il test rileva violazioni anche da route API e pagine
Astro, non solo da moduli della cartella longevity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:38:56 +02:00
Adriano 9943312071 longevity: anagrafica, unico punto di giunzione fra identita e clinica
Implementa il modulo che unisce i due database (identity e longevity)
rispettando la separazione richiesta dal cliente: pseudonimizzazione
dei dati clinici. Il modulo esporta tre funzioni:

- creaCliente: registra un soggetto con il suo codice progressivo ISL-NNNN
- codicePerUtente: risale dal user_id del sito al client_code
- etaAllaData: calcola l'eta' al momento della misura (non la data di nascita)

Un test verifica che nessun altro modulo apra entrambe le connessioni,
preservando il firewall fra dati clinici e identita'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:33:23 +02:00
Adriano 7779c6b830 test: verifica che fuoriRange non trunca il valore numerico 2026-08-21 19:31:15 +02:00
Adriano a18bb7c02f longevity: registraMisure, unico varco con validazione sul registro 2026-08-21 19:27:41 +02:00
Adriano 370dcbda56 longevity: registro dei test e pesi versionati come dato 2026-08-21 19:22:32 +02:00
Adriano 1821a0a8d4 spec: score.nome diventa score.elemento, coerente con la tabella pesi 2026-08-21 19:17:42 +02:00
Adriano ad2fc0d510 fix: rinomina score.nome in score.elemento per coerenza con tabella pesi 2026-08-21 19:17:03 +02:00
Adriano f16012c8bd longevity: schema dei due database, identity separato da longevity 2026-08-21 19:15:17 +02:00
Adriano bdc44dd3c5 chore: ignora gli artefatti di lavoro dei subagenti in .superpowers/ 2026-08-21 19:11:44 +02:00
Adriano 534833c767 docs: piano di implementazione dello strato dati longevity
Sette task in TDD: schema dei due database, registro test e pesi versionati,
registraMisure come unico varco, anagrafica che tiene separata identita' e
clinica, ruoli cliente/trainer, salvataggio del questionario, export.

Alla fine esiste una libreria testata e nessuna pagina: il motore di calcolo
e le schermate sono i due piani successivi.
2026-08-21 19:05:07 +02:00
Adriano 02d0224e5a docs: design della piattaforma IN-SANITY LONGEVITY PROJECT
Fascicolo unico del cliente dentro il sito esistente, ramo separato e nessun
deploy. Tre database: sito, identity (codice <-> nome) e longevity (misure,
mai un nome) - cosi' l'export per le statistiche e' gia' pseudonimizzato per
costruzione, come chiesto da Nicola il 21/08.

Le misure sono un registro (una riga per valore) e i test sono dato, non
codice: togliere l'agilita' o passare l'HRR da 2' a 1' non e' una migrazione.
I pesi stanno in tabella versionata, cosi' il congelamento degli score
storici ha davvero qualcosa dietro.

Punteggi congelati con due versioni distinte (domande e modello di calcolo).
Il tipo di ritorno del motore rende impossibile mostrare un numero pieno dove
la regola vuole il tratteggio.
2026-08-21 18:41:06 +02:00
Adriano ecab67805b Data dell'inaugurazione piu' grande 2026-08-19 10:34:38 +02:00
Adriano cdfbf194c2 Eventi: la data dell'inaugurazione si legge da lontano
Chiesto da Adriano il 19/08: era 0,82rem sotto un titolo che arriva a 2,6rem, e
sembrava una didascalia. Ora cresce con la finestra come il titolo, restando un
gradino sotto.
2026-08-19 10:34:12 +02:00
Adriano 76adef3fc7 Titolo e data dell'inaugurazione su due righe 2026-08-19 10:31:49 +02:00
Adriano d079019dcf Eventi: titolo e data dell'inaugurazione su due righe
Chiesto da Adriano il 19/08. Sono due tag e non un testo con un a capo dentro:
la data si cambia da sola se l'evento si sposta, e un ritorno a capo dentro un
valore di testo non sopravvive all'editor del pannello.

Il titolo vive anche nel database: in produzione va aggiornato
eventi.inaugurazione.title a "Inaugurazione", altrimenti resta la riga unica.
Il tag nuovo (.date) non e' in tabella e viene dal seed.
2026-08-19 10:30:07 +02:00
Adriano 48909e8c8b Foto del team senza logo, e la foto di Eva torna nel repo 2026-08-19 10:25:18 +02:00
Adriano cc6945c3d1 Eva: la foto della pagina About torna nel repo, senza logo
La foto di Eva in pagina non veniva dal repo ma da un file caricato dal
pannello il 12/07 (tag team.eva.image-about): lo scatto giusto, ritagliato piu'
largo, ma con il logo ancora nell'angolo. Il file del repo era l'altro ritaglio,
piu' stretto, e non lo vedeva nessuno.

Ora team-eva-dark.jpg E' quella foto, col logo rimosso: maschera dei pixel del
logo allargata di 4 px per prendere i bordi sfumati, ricostruzione biharmonic
(scikit-image) e grana del tessuto ripresa dalla stoffa 80 px piu' in alto —
una toppa liscia si sarebbe notata.

Cosi' tutte le foto stanno in un posto solo e passano dall'ottimizzazione della
build (54 KB webp invece di 177 KB jpeg servito com'e'). In produzione va
svuotato il tag team.eva.image-about, altrimenti continua a vincere il file
caricato. Originale col logo su
wasabi://.../foto-team/team-eva-dark-ORIGINALE-col-logo.jpeg; il file su
uploads/ resta dov'e'.

Il ritaglio e' 864x1240 (0.697) invece di 1547x1920 (0.806): le card ritagliano
a 3:4, quindi qui si perde qualcosa sopra e sotto invece che ai lati. Verificato
sulla card renderizzata.
2026-08-19 10:24:13 +02:00
Adriano f7a9ae47d1 Foto del team senza il logo nell'angolo
Adriano ha tolto un piccolo logo dall'angolo in basso a destra delle sette foto
scure della pagina About: alessandro, alice, donata, eleonora, eva, giulia,
nicola. Originali in wasabi://.../Sito_Web/foto-team-originali-20260819.zip.

Stesso formato di prima (JPEG 1547x1920, RGB): l'unica differenza e' un riquadro
di ~80x80 px nell'angolo, verificato confrontando le immagini. I file pesano il
doppio perche' risalvati a qualita' piu' alta, ma in pagina non cambia nulla: la
build li ricomprime in webp attorno agli 85 KB.

Nota: la foto di Eva in pagina NON e' questa, e' un ritaglio piu' largo dello
stesso scatto caricato dal pannello il 12/07 (tag team.eva.image-about), che il
logo ce l'ha ancora.
2026-08-19 10:16:29 +02:00
Adriano 9772b3a3f6 Il titolo della pagina About us 2026-08-19 09:43:49 +02:00
Adriano 32c8a510bb About us: il titolo in cima alla pagina dice About us, non Insanitylab
Segnalato da Adriano il 19/08. Il valore vive anche nel database dei contenuti,
dove viene letto per primo: il seed da solo non avrebbe cambiato la pagina, e
l'aggiornamento del tag about.hero.title va fatto in produzione.
2026-08-19 08:59:44 +02:00
Adriano 20f0c2120b Piu' aria fra l'immagine e il titolo Check-up Avanzato 2026-08-19 08:54:46 +02:00
Adriano 6e002d4dc1 Check-up: il titolo della sezione Avanzato si stacca dall'immagine
Segnalato da Adriano il 19/08: "Check-up Avanzato" era appiccicato alla foto
sopra e si leggeva come una didascalia invece che come l'inizio di una parte
nuova.

Lo stacco e' un padding e non un margine: sopra c'e' gia' il margine
dell'immagine, e due margini verticali adiacenti collassano fra loro — con
margin-top lo spazio sarebbe rimasto quello di prima.
2026-08-19 08:49:55 +02:00
Adriano 3790d6d506 Check-up senza la domanda "Che cosa comprende?" 2026-08-19 08:46:19 +02:00
Adriano e315887964 Check-up: via la domanda "Che cosa comprende?" in fondo alla scheda
Chiesta da Adriano il 19/08. Restava in coda alle FAQ e ripeteva, in versione
piu' povera, cio' che la sezione Check-up Avanzato racconta per esteso.

In pagina resta una sola domanda. I due tag del pannello
(service.profilazione.faq.2.q e .a) non sono piu' letti da nessuno.
2026-08-19 08:45:35 +02:00
Adriano 0cbaf66a5b Pubblica le modifiche di agosto: calendario delle class, promo a edizioni e le richieste del 17/08 2026-08-18 17:06:53 +02:00
Adriano 3741ce34ac Modifiche chieste dal cliente il 17 agosto
Il documento MODIFICHE_PER_SITO.pdf porta cinque cose.

1. "Small Group" si chiama "Personalized Training Group". Il nome vive anche
   nel database dei contenuti, dove `resolveContent` legge prima della copia
   nel codice: `scripts/testi-17-agosto.mjs` aggiorna le 19 righe rimaste,
   una volta sola, correggendo anche gli articoli ("Lo Small Group" -> "Il
   Personalized Training Group"). Lo slug della scheda resta `small-group`:
   cambiarlo romperebbe i link gia' girati su WhatsApp.

2. Dalla pagina Servizi le card instradano dove serve. "One to One" apre la
   vista Training con One to One, Personalized Training Group e Rehab;
   "Class" quella con Holistic e Reformer. Le viste sono `/training/<vista>`;
   una vista inventata e' 404, non l'elenco intero, altrimenti un indirizzo
   rotto continuerebbe a sembrare buono.

3. La Performance Class non si eroga piu': la scheda che la spiegava
   risponde 404, la card "Class" resta in griglia. Con la scheda e' caduta la
   tabella prezzi, che era solo sua.

4. Il Check-up racconta Base e Avanzato: trafiletto nuovo nella card e
   sezione "Check-up Avanzato" nella scheda, con la Wellness Tower e i cinque
   punti. Il calendario delle class si apre ora anche dalla card Class in
   griglia e dalla scheda Reformer, oltre che da Holistic.

5. "Promo ed eventi" diventa una pagina di scelta: le promozioni restano su
   /promo-eventi/promo, l'inaugurazione del 19 settembre sta su
   /promo-eventi/eventi. Il testo dice che il programma della giornata manca
   ancora, ed e' editabile dal pannello.

Corretti due link del footer che portavano a schede inesistenti
(`/services/personal-training` non e' mai stato uno slug).

181 test verdi (157 + 24 nuovi), build ok, `astro check` invariato.
2026-08-18 17:06:05 +02:00
Adriano 69bd17a296 Calendario: si vedono tutti gli orari validi, e il PDF ne fa una pagina ciascuno
Il popup mostrava solo l'orario del momento: chi guarda ad agosto vuole sapere
com'e' settembre per intero, e una griglia sola gli fa credere che valga per
sempre. Ora ci sono tutti quelli non ancora scaduti, ognuno con la sua etichetta
- In vigore, oppure la data da cui parte - e quelli passati spariscono da soli.

Il bottone scarica /calendario/tutti.pdf: un orario per pagina, con le giornate
fuori griglia ripetute su ognuna, perche' un foglio stampato da solo deve bastare
a se stesso. Gli id singoli restano validi per mandarne uno solo.

calendarioDaMostrare (uno) diventa calendariDaMostrare (tutti): una funzione
sola, non due che dicono cose simili - e' lo stesso difetto dei due link della
pagina servizi, che erano due espressioni per lo stesso indirizzo e sono
divergute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:22:22 +02:00
Adriano d03c62641e Calendario: il 19 e il 20 settembre non sono un buco, sono un evento e una chiusura
Quei due giorni non stanno in nessuna delle due griglie perche' non hanno class:
il 19 c'e' la presentazione della nuova apertura, il 20 si sta chiusi. Scritto
come avviso con la sua finestra, il vuoto smette di sembrare una trascrizione
incompleta e diventa un'informazione.

Gli avvisi compaiono in cima al popup e in fondo al PDF - e' il file che gira su
WhatsApp, e chi lo riceve non ha la pagina davanti. Spariscono da soli quando
sono passati: un avviso scaduto dice che il centro e' chiuso in un giorno gia'
trascorso, e chi legge non sa piu' se valga per quest'anno o per il prossimo.

Lo spazio riservato in fondo alla pagina cresce con gli avvisi, altrimenti la
griglia si allunga fino a spingerli su una seconda pagina.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:07:24 +02:00
Adriano 928de34c55 Calendario: mercoledi 17.30 e 18.15 sono vuote per davvero
Le due celle coperte dal riquadro di Excel nel foglio dal 21 settembre: Adriano
conferma che non c'e' nulla. Il dato non cambia, cambia il commento - quel vuoto
adesso e' una risposta, non una lettura mancata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:44:19 +02:00
Adriano 5be0fa787c Calendario: il PDF e' un file vero, generato dal server
"Scarica in PDF" non apre piu' la stampa del browser: e' un link a
/calendario/<id>.pdf che restituisce un documento disegnato dal dato con PDFKit,
con Content-Disposition attachment. Serve perche' il calendario si gira su
WhatsApp, ed e' cosi' che questo centro parla coi clienti: chi lo riceve non ha
il sito aperto davanti.

Niente browser headless: Chromium in un container da 2 vCPU costa centinaia di
megabyte e un processo per richiesta, per una griglia di sette colonne.

Le linee della griglia si tracciano in una passata a parte, dopo i riempimenti:
disegnandole riga per riga, il riempimento della riga seguente copriva quella
appena fatta e due lezioni uguali in fasce consecutive si leggevano come un
blocco unico lungo il doppio.

Quattro test: intestazione %PDF- e coda %%EOF (un documento non chiuso arriva
monco e il browser mostra una pagina bianca invece di un errore), una pagina
sola, nomi di file distinti. Un id sconosciuto e' 404, non un PDF vuoto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:14:02 +02:00
Adriano 01f49ab6d2 Calendario delle class: popup a pieno schermo dalle pagine Performance e Holistic
Le due griglie fornite dal cliente (1-18 settembre, dal 21 in poi) sono
trascritte come dato e ridisegnate nello stile del sito: si leggono da telefono,
si stampano in PDF e per correggere una cella non serve rifare uno screenshot.

Quale griglia compare lo decide la data, come per le edizioni della promo: quella
in vigore, oppure - se nessuna lo e' ancora - la prossima, dichiarando da quando
entra. Serve perche' il link esiste da subito e ad agosto un "nessun calendario"
non direbbe niente a chi vuole gli orari di settembre.

Il popup e' un <dialog> nativo: fondale, Esc e trappola del focus senza
librerie. "Scarica in PDF" stampa la sola griglia, gia' impaginata in orizzontale
dal CSS di stampa. Le discipline hanno una barra colorata a sinistra oltre al
fondo, cosi' restano distinguibili anche quando il browser stampa senza sfondi.

Quattordici test sul dato: confini al secondo in ora italiana, fasce orarie
uniche e in ordine, nessuna lezione in un giorno di chiusura, e il buco vero fra
il 19 e il 20 settembre resta dichiarato invece che nascosto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:58:47 +02:00
Adriano a18a505254 Servizi: foto e "Per saperne di piu'" della stessa card portano allo stesso posto
Erano due espressioni diverse per lo stesso link e sono divergute: su One to One e
Performance Class la foto apriva la scheda del servizio mentre il testo mandava a
/training, quindi la destinazione dipendeva da dove capitava il dito. Adesso
l'indirizzo lo calcola una funzione sola, usata da entrambi. Verificato sulla
pagina renderizzata: sei card, zero link discordanti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:38:03 +02:00
Adriano cbcb533e57 Promo: un'anteprima non applicata lo dice, invece di mostrare oggi in silenzio
Senza login (o con una data illeggibile) il parametro veniva ignorato e la pagina
rispondeva con l'edizione corrente: due date diverse davano lo stesso risultato,
e si legge come una funzione rotta invece che come un accesso mancante. Ora una
barra rossa dichiara il motivo e come rimediare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:32:57 +02:00
Adriano 0b5d9f4106 Promo: le edizioni si scambiano da sole alla data stabilita
La pagina non mostra piu' una promozione sola scritta nel codice: sceglie
l'edizione la cui finestra contiene l'istante della richiesta. Il 20 agosto alle
00:00 la promo agosto lascia il posto agli Opening Bundles senza deploy, e dopo
il 4 settembre la pagina dichiara che non c'e' nessuna promozione invece di
lasciare online prezzi scaduti.

Le edizioni sono dato (data/promo-edizioni.ts): finestra, quante card, quali
campi ha ciascuna. I testi restano tag modificabili dal pannello, col prefisso
dell'edizione. I bundle aggiungono domanda, descrizione e riquadro del periodo
di utilizzo, presi dal volantino del cliente.

Le date portano l'offset esplicito: il server gira in UTC e senza offset lo
scambio avverrebbe due ore prima di quanto scritto sul sito. La pagina esce con
Cache-Control: no-store, perche' una copia in cache la congelerebbe all'edizione
vecchia.

Dieci test: le finestre non si sovrappongono e non lasciano buchi, il countdown
scade quando finisce l'edizione, e ogni tag richiesto esiste nel seed - un tag
mancante non romperebbe la pagina, lascerebbe una card senza prezzo.

Anteprima con ?anteprima=<data>, solo per chi e' loggato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:27:31 +02:00
Adriano cd73906c08 Migliora il layout mobile delle sezioni home
MethodSteps passa a una sola colonna con step in orizzontale, PartnersStrip
riduce loghi e spaziature, TrainingCards ridimensiona testi e padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:17:15 +02:00
Adriano Dal Pastro d6c6597d0a Ignora i backup .env.bak*
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:26:43 +00:00
Adriano 97bf270906 Lista articoli del blog per revisione cliente (md + pdf)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:58:13 +02:00
Adriano 63417fb8ea FAQ promo eventi: rimando WhatsApp e riscrittura FAQ 1 e 2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:51:30 +02:00
Adriano 9000c78a22 Aggiunge FAQ acquisto pacchetti in cima alla promo eventi
Nuovo tag promo.faq.0 (q/a) numerato 0 per evitare di rinumerare le FAQ
esistenti: il seed è INSERT OR IGNORE e non riscriverebbe i tag già
presenti nei DB, causando disallineamento tra domanda e risposta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:24:32 +02:00
Adriano 12f6b088de README aggiornato: piattaforme, Stress Index, cestino del blog e note di deploy
Documenta il ruolo piattaforme al posto di campus, la piattaforma Stress
Index con le scelte fatte sullo stile e sulle isole React, il cestino
degli articoli e il caricamento degli articoli via script, oltre alle
trappole incontrate: il percorso nuovo del progetto Campus, la cache
dell'indice in sviluppo e la lettura del database in WAL dopo il deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:03:52 +02:00
Adriano ab2797e487 Merge: piattaforme riservate, Stress Index, pannello rinnovato e promo agosto
Porta in main il lavoro di questa sessione: il ruolo piattaforme al posto
di campus con il menu dedicato, la dashboard Stress Index tradotta nello
stile del sito, l'area riservata ridisegnata con cestino degli articoli e
gestione utenti rivista, il Campus riallineato alle 167 guide del corso,
venti articoli divulgativi nel blog e la promo di agosto con countdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:21:39 +02:00
Adriano 11452ea7a7 Blog: numeri delle tematiche fra parentesi
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:21:18 +02:00
Adriano d8862ba8ec Blog: numero di articoli accanto a ogni tematica
I filtri non dicevano dove ci fosse materiale: si sceglieva una categoria
per scoprirla vuota. Ora ognuna porta il proprio conteggio, cestino e
bozze esclusi, e il numero si aggiorna insieme ai filtri quando si cambia
tematica senza ricaricare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:16:39 +02:00
Adriano b7f4a7948c Blog: venti articoli divulgativi tratti dal corso base del Campus
Dieci articoli tecnici a firma Nicola Antonelli e dieci contenuti
educativi a firma Donata Dal Pastro, ricavati dalle lezioni del corso
base: sonno, movimento, proteine, freddo, respirazione, luce, recupero,
dati, alimentazione e salute del cervello. Taglio breve e concreto, per
chi frequenta la palestra e tiene alla salute senza essere del mestiere.

Gli articoli arrivano da uno script versionato invece che dal pannello,
perché il database in produzione è un volume del VPS e non viene toccato
dal deploy: creati a mano in locale sarebbero rimasti in locale. Lo script
salta gli slug già presenti, quindi si può rieseguire senza danni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:10:43 +02:00
Adriano 5590a552cc Campus: allineate le 49 Q&A mancanti e corretto il percorso del sorgente
Il progetto Biohacking Campus è stato spostato in Wasabi-Adp-Work, mentre
sync-campus puntava ancora al vecchio percorso, ormai vuoto: da lì in poi
nessun aggiornamento è più arrivato. Il capitolo Q&A si era fermato alla
numero 35, mentre nel progetto ne esistono 84.

Ora la sezione conta 167 guide invece di 118. L'elenco dei Q&A è
rigenerato per intero dal filesystem, in ordine di numero, così il prossimo
allineamento è una sola riga di script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:53:57 +02:00
Adriano 770073c45c Blog: cambio categoria e pagina senza ricaricare tutto
Ogni clic su una categoria ricaricava l'intera pagina, header e hero
compresi, con lo sfarfallio che ne consegue. Ora si scarica la stessa
pagina e si sostituiscono soltanto la griglia e i filtri, con una breve
dissolvenza; l'indirizzo resta corretto e avanti/indietro funzionano.

I link restano link veri: senza JavaScript, o se la risposta non ha la
forma attesa, la navigazione avviene come prima.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:51:37 +02:00
Adriano 6a97a98325 Articoli: cestino con ripristino, e azioni a icona come in Utenti
Eliminare un articolo non lasciava nulla di recuperabile: ora la riga
finisce nel cestino (colonna deleted_at, aggiunta anche ai database già
esistenti) e sparisce da sito e pannello, ma resta ripristinabile dalla
nuova pagina /admin/cestino, dove sta anche la cancellazione definitiva.
L'API la accetta solo su articoli già cestinati, così il contenuto non si
perde per un clic distratto.

Ogni lettura pubblica — elenco, recenti, archivio per mese, pagina del
singolo articolo — filtra il cestino, e chi non gestisce vede nel cestino
soltanto i propri articoli.

Le azioni di riga diventano icone (matita e cestino) come nell'elenco
utenti, con etichetta accessibile che nomina l'articolo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:47:27 +02:00
Adriano 59d6246c77 Utenti: pannello password, ruolo come pillola e azioni a icona
Le due azioni di riga diventano icone (chiave e cestino) con etichetta
accessibile: pesavano quanto il contenuto della tabella. Il ruolo smette
di essere il menu a tendina di sistema e prende la forma di una pillola
colorata per livello, restando un <select> che salva al cambio.

La chiave apre un pannello dove la password si può scrivere a mano oppure
far generare: l'endpoint accetta ora una password scelta, con lo stesso
minimo di otto caratteri della creazione, e continua a restituirla in
chiaro una volta sola perché nel database ne resta solo l'hash. Il valore
si copia con un pulsante invece che dal prompt del browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:38:18 +02:00
Adriano 15924544b4 Utenti: creazione su pagina dedicata, come per gli articoli
L'elenco aveva il modulo di creazione appiccicato sopra la tabella: ora
c'è il pulsante "+ Nuovo utente" che porta a /admin/users/new, dove il
modulo ha spazio per etichette vere e per spiegare cosa comporta ogni
ruolo. A creazione avvenuta si torna all'elenco.

Nel menu la voce "Sito" diventa "Vai a sito", più esplicita sul fatto che
porta fuori dal pannello.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:30:53 +02:00
Adriano 03510d3940 Area riservata: menu essenziale e veste grafica del sito
Il menu in alto si riduce alle voci che servono davvero — Articoli,
Contenuti, Utenti, Sito, Esci — con la voce corrente evidenziata; ognuna
resta visibile solo a chi ha il ruolo per aprirla, e in pagina di login la
barra mostra il solo marchio. "Nuovo" esce dal menu perché il pulsante per
creare un articolo è già in cima all'elenco Articoli.

Il pannello smette di essere un foglio di stile a sé: adotta i font e la
palette del sito, come Campus e Stress Index, con tabelle, pulsanti, campi
e pillole di stato riportati allo stesso linguaggio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:28:35 +02:00
Adriano 8207627425 Porta la promo agosto nel branch piattaforme, per vederle insieme in locale
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:24:02 +02:00
Adriano 3cda76b4c4 Stress Index: Analytics e Sport rifatti sulle viste reali
Le due sezioni erano ferme al prototipo locale, più povero della
piattaforma vera. Analytics ha ora l'intervallo di date, i KPI con la
nota sotto il valore, la distribuzione dello score a quattro serie per
fascia, le classifiche per recupero medio col numero di misurazioni e il
confronto segmenti con selettore di dimensione. Sport diventa Dashboard,
Sessioni con filtri atleta/periodo/sport e schede atleta con carico e
tendenza.

I numeri non sono più costanti scritte a mano: si calcolano dai dati nel
periodo scelto. La logica sta in lib/stress-index/analytics.ts e sport.ts,
coperta da test — che hanno subito trovato uno slittamento delle settimane
dovuto alle date costruite in ora locale invece che in UTC.

Le sessioni hanno ora ora di inizio, HR max, RPE e durata al secondo, che
il prototipo non prevedeva.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:20:40 +02:00
Adriano c4eb598a3a Piattaforme riservate: ruolo dedicato e port della dashboard Stress Index
Il ruolo `campus` diventa `piattaforme` e non apre più solo la sezione
Biohacking: copre tutte le aree riservate, con migrazione guardata sugli
utenti esistenti. Nell'header la vecchia voce Biohacking lascia il posto a
un menu Piattaforme con i due link, visibile solo a chi ha il ruolo, e
/piattaforme diventa la pagina di atterraggio.

Stress Index è portata dentro il sito: le nove viste dell'area
professionisti diventano pagine Astro sotto /piattaforme/stress-index,
con lo stile del sito al posto del design system del prototipo. React
resta solo dove serve davvero — grafici recharts e scheda cliente — il
resto è Astro con un filo di JS. I dati sono ancora quelli dimostrativi,
raccolti in lib/stress-index/data.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:49:47 +02:00
Adriano 78a920b4e2 Promo agosto 2026: nuovi prezzi, validità 1-19 agosto e countdown
Prezzi aggiornati (One to One 200, Small Group 125, Reformer 115,
Holistic 75) con migrazione guardata sul vecchio valore, così le righe
già ritoccate dal pannello non vengono sovrascritte. I barrati restano
invariati. Nota di validità con la finestra 1-19 agosto e countdown alla
scadenza sopra le card: la deadline è un tag editabile, il blocco si
nasconde da solo quando la promo è finita.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:36:27 +02:00
Adriano e8e24e086b Merge: sezione Biohacking riservata agli utenti abilitati
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:41:53 +02:00
Adriano 2f0df9c92c Link alla gestione utenti nella sidebar del Biohacking
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:21:32 +02:00
Adriano f534263c9c Rimuove il limite di tentativi dal login
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:57:59 +02:00
Adriano 6b00b2cb28 Il logout riporta alla home del sito
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:47:21 +02:00
Adriano d8579c0a59 Accesso al Biohacking dal menu del sito e dal login pubblico
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:43:46 +02:00
Adriano 98beccb0cd Rinomina in Biohacking le etichette visibili della sezione
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:47:47 +02:00
Adriano c4107b84a5 Sezione Campus: layout dedicato, route protette e documentazione
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:40:07 +02:00
Adriano 7baebebc09 Script sync-campus: importa le guide del corso in campus-content/
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:30:59 +02:00
Adriano a7d918d241 Ruolo campus: accesso limitato a /campus, landing dedicata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:26:35 +02:00
Adriano 20f7cf4003 Spec: sezione campus riservata con ruolo dedicato
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:25:21 +02:00
Adriano 669fd2978f Hero one-to-one: titolo "Personal Training" separato dall'H2
Campo opzionale Service.heroTitle: l'hero (H1) di /services/one-to-one
mostra "PERSONAL TRAINING" mentre H2/card restano "One to One" (tag
condiviso service.one-to-one.title invariato). Nessuna migrazione DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:54:59 +02:00
Adriano b17b5c831a Fix contatti + rinomina servizi + orario sabato
- Contatti: rate-limit per IP reale (X-Forwarded-For) invece dell'IP del
  proxy Traefik, condiviso da tutti; controllo spostato dopo la validazione
  così bot/honeypot non consumano la quota. Honeypot rinominato da "website"
  a "hp_field" per non farlo riempire dall'autofill del browser (falsi 400).
- Servizi: "Performance Class" -> "Class" (titolo/card/H1/footer); migrazione
  DB invertita e guardata.
- One to One: migrazione titolo prod "Personal training" -> "One to One".
- Footer: orario sabato 8-17 (non 8-19), seed + migrazione guardata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:58:26 +02:00
Adriano 22c9cadb43 Migrazione: rimuove i tag orfani training.performance-class.*
Il riquadro training "performance-class" è stato rimosso dal seed nel commit
9966ec3, ma i suoi 5 tag restavano nei DB esistenti come orfani non
renderizzati (uno con un edit errato dell'editor, "Holistic Class"). Aggiunge
una DELETE idempotente per l'intero gruppo; il seed non li reinserisce
(trainings.ts non ha più questo id). Verificata su copia del DB prod: 5 -> 0
orfani, riquadri validi e 39 edit reali del pannello intatti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:16:52 +02:00
Adriano 21d9755e37 Migrazione: corregge il titolo Performance Class troncato a "Class"
Nei DB esistenti (prod + dev) service.performance-class.title era rimasto
"Class", mostrando "CLASS" come H1 della pagina /services/performance-class
e nelle card di /services e home. Il seed è INSERT OR IGNORE e non lo
aggiornava; una nuova installazione era invece corretta.

Aggiunge una migrazione guardata dal vecchio valore (idempotente, non tocca
le modifiche del pannello) che allinea a "Performance Class". Verificata su
copia del DB di produzione: titolo corretto, 40 edit del pannello intatti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:41:37 +02:00
Adriano ee54e9a1f4 Migrazione: allinea i bottoni promo col vecchio testo-freccia
Il commit 1522d97 ha spostato la freccia "→" dei bottoni promo nel CSS
(::after) e il maiuscolo in text-transform, cambiando il seed in minuscolo
senza freccia. Ma il seed è INSERT OR IGNORE: sui DB esistenti (produzione)
le righe restavano a "LE NOSTRE PROPOSTE →" / "ACQUISTA →", e il ::after
avrebbe prodotto una doppia freccia.

Aggiunge una migrazione guardata dal vecchio valore (idempotente, non tocca
le righe modificate dal pannello) che le allinea al seed corrente. Verificata
su copia del DB di produzione: frecce corrette e 40 edit del pannello intatti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:28:20 +02:00
Adriano 62e0120ea2 Footer: rimuove il form contatti e riorganizza in 3 colonne
- Rimossa la colonna "invia messaggio" (nome, cognome, telefono, mail,
  messaggio) e l'import ContactForm inutilizzato.
- Griglia a 3 colonne con contenuto centrato, logo incluso.
- Training e Servizi allineati alla riga "Promuoviamo il movimento...".
- Altezza del footer ridotta (padding compattato).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:48:49 +02:00
Adriano 2f94fc0373 Allinea test stale e migrazione nav dopo riordino menu
I test erano rimasti indietro rispetto a due modifiche intenzionali:
- size 'xxl' è ora un valore valido (whitelist + CSS): i test lo usavano
  come esempio di valore da rifiutare → sostituito con 'xxxl'.
- il menu è stato riordinato (commit 5ff4771): la voce "Servizi" è passata
  da nav.3 a nav.2. Allineata la voce di migrazione OLD_SECTION_DEFAULTS a
  global.nav.2.label e il relativo test. Impatto nullo sui DB reali (nessuna
  riga ha ancora value 'Programmi'), solo coerenza semantica.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:17:37 +02:00
Adriano e6c493b756 Contatti obbligatori, hero senza foto, menu Promo2 solo admin
- Contatti: nome, cognome, telefono e mail resi obbligatori (validazione
  client + server); il form del footer ora include cognome e telefono.
- Home hero: rimossa la foto (file conservato su disco), fascia ridotta
  da 100vh a padding contenuto, testo allargato.
- Menu: la voce "Promo 2 (test)" è visibile solo agli admin loggati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:06:28 +02:00
Adriano 1522d97f54 promo: freccia bottoni via ::after (modifiche dal canvas Claude Design)
Riporta nel codice le modifiche fatte da Adriano nel canvas di Claude Design: la
freccia dei bottoni diventa decorazione CSS (::after) invece di far parte del testo
del tag. I tag promo.hero.cta e promo.buy-label perdono la freccia e il maiuscolo
forzato (gestiti dal CSS .btn). promo-eventi2 usa la ::after globale di .btn;
promo-eventi (.lp) aggiunge ::after ai suoi bottoni custom per non perdere la freccia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:08:20 +02:00
Adriano cc21cb7627 promo: riabilita i controlli di stile sui badge nel pannello contenuti
promo.hero.badge e promo.launch.badge tornano al set di stili di default
(size/color/weight/style/align): erano stati seedati con styleOptions vuoto,
quindi il pannello mostrava solo il testo senza opzioni di stile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:22:37 +02:00
Adriano 27fdf1fc30 promo-eventi2: raffinamento estetico
Claim su due righe pulite (niente più spezzatura del corsivo), titolo FAQ reso
visibile e uppercase come lo stile del sito, card con prezzi più prominenti,
separatore feature, ombra/hover e featured evidenziata, spaziature bilanciate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:26:52 +02:00
Adriano ae5eef037b promo-eventi2: adotta i pattern di layout del sito (PageHero, section, sfondo bianco)
La prima versione unificava solo font/colori ma teneva il box centrato su sfondo crema.
Ora la pagina di test usa il banner PageHero a tutta larghezza, sezioni .section/.container
su sfondo bianco e bottoni .btn globali, come Servizi e Training. Aggiunto il tag
promo.pagehero.title per il titolo del banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:11:09 +02:00
Adriano 931c1e43ad Aggiunge pagina di test promo-eventi2 con stili unificati al design system
Variante di /promo-eventi con font Montserrat/Open Sans e palette --c-* al posto di
Inter e dei colori hardcoded; struttura e contenuti identici (stessi tag promo.*).
Root class `.lp2` per isolare gli stili is:global dalla pagina originale. Aggiunta la
voce di menu "Promo 2 (test)" → /promo-eventi2 per il confronto affiancato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:51:42 +02:00
Adriano a7b05d703a Promo ed eventi: testi editabili dal pannello con stili is:global
Riprende il tagging della landing (tag promo.* + global.whatsapp centralizzato) e
risolve la regressione visiva: gli elementi editabili sono renderizzati dal componente
<T>, fuori dallo scope della pagina, quindi il CSS scoped non li raggiungeva (font e
allineamenti persi). Lo <style> diventa is:global; ogni regola è già prefissata `.lp`,
quindi resta confinata alla landing senza toccare il resto del sito.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:43:40 +02:00
Adriano 4cee8cdd7a Revert "Rende la pagina Promo ed eventi modificabile dal pannello contenuti"
This reverts commit 1742c1d666.
2026-07-13 11:54:10 +02:00
Adriano 1742c1d666 Rende la pagina Promo ed eventi modificabile dal pannello contenuti
Tutti i testi della landing (hero, launch, card con prezzi, nota validità e FAQ)
passano ora dal sistema di content-tag `promo.*`, editabili inline e da pannello.
Il numero WhatsApp è centralizzato nel tag globale `global.whatsapp`: le risposte
FAQ usano il segnaposto {{whatsapp}}, sostituito a runtime, così si aggiorna in un
punto solo. Struttura delle 4 card e link di acquisto bsport restano nel codice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:51:21 +02:00
Adriano 0b938d7d09 Aggiunge integrazione Google Tag Manager attivabile via env
GTM viene caricato nel layout Base solo se la variabile GTM_ID è valorizzata.
L'ID è letto a runtime da process.env (non inlinato a build-time), così si
attiva/disattiva senza ricompilare. Con GTM_ID vuoto il sito non carica alcuno
script Google.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:59:34 +02:00
Adriano 47a93049bb editor inline: restyle e combo con valore impostato pre-selezionato
- GET /api/admin/content/[tag] restituisce anche gli styles correnti del blocco
- Modal ridisegnato (header con chip tag, input arrotondati, griglia combo)
- Combo etichettate in italiano (Dimensione/Colore/Peso/Stile/Allineamento) con
  valori leggibili; pre-selezione del valore già impostato ed evidenziazione

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:18:08 +02:00
Adriano 5ff47711cd menu: riordina le voci (Training, Servizi, About us, Blog & Edugo, Promo, Contatti)
I label del menu vengono da global.nav.N.label (per posizione): riordinati anche
i blocchi contenuto in produzione per allinearli al nuovo ordine degli href.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:54:55 +02:00
Adriano f48518d8fd footer e contatti: social aggiornati e nome sulla mappa
- Footer: Instagram -> in_sanity_lab, LinkedIn -> in-sanity-lab-srl, rimosso Facebook
- Contatti: mapQuery con nome "IN-SANITY LAB" così la mappa mostra l'etichetta

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:48:58 +02:00
Adriano 30b509436e contenuti: aggiunge size xl/xxl e allineamento (con giustificato)
- style-presets: size += xl, xxl; align += justify
- global.css: .cs-size-xl (1.5em), .cs-size-xxl (2em), .cs-align-justify
- content-seed: 'align' nei default styleOptions dei tag testo/html, così il
  picker orientamento (sx/centrato/dx/giustificato) compare nell'editor
- Base.astro: sincronizza la copia hardcoded degli stili nell'editor inline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:48:58 +02:00
Adriano 8f47cf9e94 about: rende visibili gli a-capo in about.studios.body3
Il <p> reso da <T> collassava i newline (white-space: normal). Aggiunto
white-space: pre-line al paragrafo, ancorato al contenitore col cid via :global.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:19:29 +02:00
Adriano 9f6cf29708 menu: rinomina voce "Insanitylab" in "About us"
Aggiornato il seed del label nav (site.ts). Il valore renderizzato viene dal
blocco taggato global.nav.1.label, aggiornato anche in produzione.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:15:36 +02:00
Adriano 3183bd795d training: nasconde la sezione Agenda
Rimosso il render di <Agenda /> (e l'import) da /training. Il componente
Agenda.astro resta disponibile per riuso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:12:39 +02:00
Adriano 1b9a3c1343 Servizi home, testo promo, fix immagini override e rimozione Antonello
- ServicesGrid: filtra grid !== false come /services (via Small Group, Rehab,
  Holistic Class, Reformer Class dalla griglia Servizi in home)
- promo-eventi: badge "solo per apertura nuova sede Settembre 2026"
- MethodSteps e about: .tcard/.method__circle usano :global(img) così gli
  override <img> di <TImg> (senza cid) ricevono border-radius/object-fit
- about: griglia team a flex centrato, ultima riga incompleta non piu a sinistra
- team: rimosso Antonello D'Alessandro (staff about e homeTeam)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:01:52 +02:00
Adriano 3b8983dca5 fix(img): foto Reformer Class in alta risoluzione
Sostituita la foto piccola (770px) con la versione HD della stessa scena
reformer (1600x1066) dagli asset Figma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:41:51 +02:00
Adriano 3b4d38e087 fix(img): foto corretta per Holistic Class
La foto di Holistic Class mostrava macchinari Reformer (era la stessa immagine
di Reformer Class). Sostituita con una foto Pilates/Yoga a corpo libero (due
persone di età diverse, coerente con 'adatta a tutti i livelli'), dagli asset Figma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:32:19 +02:00
Adriano 02b5180227 fix(metodo): allinea i testi degli step del Metodo ai valori voluti
Gli step del blocco Metodo (home) erano stati riscritti dal pannello ma solo nel
DB di sviluppo: il database non è versionato, quindi non erano mai arrivati in
produzione (che mostrava ancora i default del seed). Aggiornato il seed method.ts
(Check-up fisico, Insanity plan, Monitoraggio costante + testi) e aggiunta
migrazione idempotente che allinea le righe di prod ancora ai vecchi default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:32:44 +02:00
Adriano c722680590 chore(deploy): vecchio insanitylab.tielogic.xyz → 301 all'apex
Conclusa la migrazione al dominio ufficiale: il sottodominio di transizione
non serve più il sito ma redirige permanentemente a insanitylab.it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:25:45 +02:00
Adriano 172cef236b feat(gdpr): conformità privacy — mappa a consenso, pagine legali, titolare
- Mappa Google su /contact ripristinata a click-to-load (consenso esplicito):
  nessun dato inviato a Google prima del click dell'utente
- Nuove pagine /privacy e /cookie-policy (informativa art. 13 GDPR e cookie),
  compilate con i dati del titolare IN-SANITY LAB S.R.L. (P.IVA 03927341200)
- Footer: ragione sociale + P.IVA e link a Privacy e Cookie Policy
- Form contatti: link all'informativa privacy sotto l'invio
- sitemap: aggiunte /privacy e /cookie-policy

Testi legali da far validare; base tecnica conforme (no analytics/tracker,
solo cookie tecnici, font self-hosted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:19:57 +02:00
Adriano 1aa82f5332 docs: README unico e aggiornato; rimozione piani/spec superati
- README riscritto come documentazione viva e completa: stack, funzionalita'
  (contenuti taggati + GUID + modifica inline, 3 ruoli admin/superuser/user,
  blog ownership, gestione utenti), env SMTP Aruba, deploy Docker/Traefik,
  dominio insanitylab.it, migrazioni DB e trappole tecniche note
- rimossi docs/superpowers/plans e docs/superpowers/specs: piani gia' eseguiti
  e spec di design ora accorpati nel README (restano nella storia git)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:50:21 +02:00
Adriano d79aa912b1 feat(deploy): dominio ufficiale insanitylab.it (apex canonico)
- astro.config: site=https://insanitylab.it, allowedDomains apex+www+tielogic
- compose/Traefik: router serve insanitylab.it e (in transizione) tielogic.xyz;
  www.insanitylab.it redirige 301 all'apex. TLS Let's Encrypt via certresolver.
  tielogic resta servito per non avere downtime finché il DNS non propaga;
  il suo redirect all'apex verra' attivato dopo la conferma che il dominio e' live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:20:38 +02:00
Adriano 86d608a929 fix(hero): titolo su 3 righe (PERFORMANCE./BALANCE./LONGEVITY.)
Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
di HeroSlider, quindi le regole scoped .hero__line (display:block e animazioni)
non lo colpivano e le righe restavano inline. Ancorato a .hero__text/h1 (che il
cid ce l'hanno) con :global(.hero__line), come già fatto per .prog__desc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:54:50 +02:00
Adriano 708410bbf1 feat(home): rimuove le sezioni Agenda e Team dalla pagina principale
Tolte da index.astro le sezioni <Agenda> e <TeamSection> con i relativi import.
I componenti restano nel codice (riutilizzabili altrove). Modifica isolata su
branch dedicato per valutazione col cliente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:47 +02:00
Adriano 53af0ca00e feat(home): nuova immagine per lo step "Insanity plan" del Metodo
Grafica di brand (barre di progressione, curva, checklist protocolli) al posto
della vecchia foto, coerente col testo aggiornato dello step 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:40:57 +02:00
Adriano 6594f6da53 docs(env): configurazione SMTP Aruba per il form contatti
smtps.aruba.it:465 con casella autenticata del dominio (il mittente deve
coincidere con la casella SMTP). Il dominio invia già la posta via Aruba
(SPF include:aruba.it). Credenziali reali solo nel .env (git-ignored) e sul VPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:41:20 +02:00
Adriano c6de02be83 Contatti, /training e immagini: rifiniture post-merge
- Rimossa la sezione "Prenota la lezione di prova" da /training (markup,
  import, CSS, seed e blocchi DB relativi)
- Titoli dei riquadri Training non più forzati in minuscolo: rispettano il
  testo del DB (One to One, Small Groups, ecc.)
- Footer e pagina Contatti con i due ingressi dello stesso stabile
  (Via Leandro Alberti 76 e Via De Mattiolo 5) più l'email; nuovo tag
  global.contact.map-query e migrazione DB idempotente dell'indirizzo
- Mappa Google in /contact caricata automaticamente e a piena dimensione
  (rimosso il bottone e il relativo script)
- Landing Promo ed eventi: rimossa la barra contatti in fondo
- service-icons allineate agli slug attuali (one-to-one, small-group;
  rimosse le chiavi obsolete personal-training e pilates-flow)
- Immagini: foto Rehab corretta (prima era di osteopatia) su program-rehab
  e training-rehab; bande nere letterbox rimosse dalla foto di osteopatia;
  nuova immagine grafica per Profilazione con grafici HRV (ECG, tacogramma
  R-R, Poincaré plot)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:41:37 +02:00
Adriano 11fc2b3e54 Servizi e landing Promo ed eventi: rinomine, pulizia contenuti e nuova pagina
Servizi:
- Personal Training rinominato "One to One" (slug /services/one-to-one) con
  redirect 301 dal vecchio URL e migrazione DB idempotente dei contenuti taggati
- Nuova pagina "Small Group" (slug /services/small-group, fuori dalla griglia)
- Rimossi i titoli intro di /training e /services e le liste feature di One to
  One, Small Group, Rehab e Osteopatia; la lista feature ora è condizionale
- Fix elenco puntato nel long-description (white-space: pre-line, selettore
  ancorato al container per gli elementi resi dal componente <T>)
- Immagine di Performance Class aggiornata
- Nella griglia Servizi i riquadri "One to One" e "Performance Class" rimandano
  a /training; nella pagina Training i riquadri One to One e Small Group aprono
  le rispettive schede servizio

Landing:
- Pagina "Promo ed eventi" (slug /promo-eventi) integrata nel sito con header e
  footer condivisi, basata sul nuovo mockup di lancio
- Card a 5 incontri con prezzi edizione apertura, FAQ ad accordion e acquisto
  tramite gestionale bsport
- Nuova voce di menu "Promo ed eventi"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:33:11 +02:00
Adriano 5b34ee1697 Home: bottone Contattaci in fondo alla sezione Metodo
Come da documento cliente. Nuovo tag home.method.cta (auto-seed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 17:34:57 +02:00
Adriano 2468ce95f7 About: testi aggiornati e rimozione sezioni Metodo, Vision, StoryLab
- Testi riscritti da documento cliente: about.about.body, convenzioni.item1/item2
  (rimosso "gratuita"), studios.body1 (versione breve). Seed + migrazione DB
  idempotente (guardia via frammento LIKE, rispetta le modifiche da pannello).
- Rimosse dalla pagina le sezioni marcate "LEVARE" nel documento: Timeline/StoryLab,
  Metodo (presente in home) e Vision. I tag restano nel seed per un eventuale ripristino.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 16:43:50 +02:00
Adriano 9966ec3616 Training: 6 riquadri (Rehab, Holistic Class, Reformer Class, Coaching)
- trainings.ts: da 4 a 6 riquadri (One to One, Small Group max 4, Rehab,
  Holistic Class ex Performance Class, Reformer Class nuova, Coaching ex Fit
  Remote); anche in Homepage. CTA "Scopri" verso le pagine dettaglio.
- services.ts: aggiunte pagine dettaglio rehab/holistic-class/reformer-class con
  flag grid:false (fuori dalla griglia /services ma raggiungibili come
  approfondimento dai riquadri Training). Rehab torna consultabile su /services/rehab.
- index/[slug] servizi filtrano grid!==false; rimosso redirect /services/rehab.
- Footer Training portato a 6 voci con ancore aggiornate; TrainingCards tone-safe.
- Foto Reformer dal documento cliente; card Rehab/Holistic/Coaching con
  immagini placeholder (da confermare). Migrazione DB idempotente per label/meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 13:26:58 +02:00
Adriano 140146fce1 Servizi: Pilates Flow -> Profilazione, Rehab -> Osteopatia
- services.ts: nuovi servizi profilazione (copy bozza dal metodo ISL) e
  osteopatia (copy dal documento cliente); rehab esce da Servizi (va in Training).
- Redirect 301 dai vecchi slug: pilates-flow -> profilazione, rehab -> training.
- Footer, icone, sitemap e meta aggiornati; migrazione DB idempotente per le
  label footer e la meta description (contenuti live in SQLite).
- Immagine osteopatia riusa la foto rehab (come da mockup cliente).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 13:10:37 +02:00
Adriano 7211d74d3a fix(sicurezza): safeNext blocca backslash (open redirect) + guardia ownership su /admin/edit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:34:16 +02:00
Adriano e2bbc9074f fix(create-user): ruoli aggiornati a superuser/user (gap dal piano) 2026-07-05 22:23:32 +02:00
Adriano be77285613 test(smoke): login pubblico, badge, protezione /admin/users 2026-07-05 22:21:30 +02:00
Adriano c36ad4cb2d feat(users): gestione utenti /admin/users con guardie self/ultimo-admin 2026-07-05 22:18:20 +02:00
Adriano 0f2b2ba014 feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab 2026-07-05 22:15:30 +02:00
Adriano 2bf6b3d4de feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore 2026-07-05 22:13:15 +02:00
Adriano 22d72be74d feat(inline): badge con guid + modal di modifica contenuti in pagina 2026-07-05 22:10:50 +02:00
Adriano 842371604b feat(showtags): endpoint cookie + toggle nel menu utente 2026-07-05 22:05:46 +02:00
Adriano 71cd1626e0 feat(header): identità utente SSR, link Accedi / menu utente 2026-07-05 22:03:07 +02:00
Adriano 6b85872515 feat(login): pagina pubblica /login con next sicuro 2026-07-05 21:59:50 +02:00
Adriano 5ce10da9bd feat(content): guid in resolve/componenti + GET /api/admin/content/[tag] 2026-07-05 21:56:53 +02:00
Adriano c3267c9be2 feat(posts-api): assegna autore in POST, ownership su PUT/DELETE 2026-07-05 21:51:34 +02:00
Adriano 6d1e2607ee feat(posts): author_id nel repository + listByAuthor + helper utenti 2026-07-05 21:49:16 +02:00
Adriano db92fc9c02 feat(middleware): autorizzazione a matrice + redirect per ruolo 2026-07-05 21:47:24 +02:00
Adriano e87a464090 feat(auth): matrice permessi 3 ruoli + landingFor 2026-07-05 21:44:30 +02:00
Adriano 5cea895d51 feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id 2026-07-05 21:40:28 +02:00
Adriano d4cf359805 docs: rifinisci test migrazione nel piano 2026-07-05 21:38:47 +02:00
Adriano d0fa75a3d3 docs: piano implementazione ruoli/login/GUID/modifica inline 2026-07-05 21:37:53 +02:00
Adriano 6c225281e0 docs: spec ruoli/login/GUID/modifica inline 2026-07-05 21:14:30 +02:00
129 changed files with 7221 additions and 5530 deletions
+14 -5
View File
@@ -1,8 +1,17 @@
SMTP_HOST=smtp.example.com # SMTP Aruba — il dominio insanitylab.it invia la posta via Aruba (SPF: include:aruba.it)
SMTP_PORT=587 # Server posta in uscita autenticato: smtps.aruba.it:465 (SSL). Verificato funzionante.
SMTP_USER=user SMTP_HOST=smtps.aruba.it
SMTP_PASS=pass SMTP_PORT=465
# Casella reale del dominio usata per l'invio autenticato (con la sua password Aruba)
SMTP_USER=postmaster@insanitylab.it
SMTP_PASS=la-password-della-casella
# Aruba impone che il mittente sia la casella autenticata: uguale a SMTP_USER
CONTACT_FROM=postmaster@insanitylab.it
# Dove arrivano i messaggi del form
CONTACT_TO=info@insanitylab.it CONTACT_TO=info@insanitylab.it
CONTACT_FROM=sito@insanitylab.it
DB_PATH=data/insanitylab.db DB_PATH=data/insanitylab.db
UPLOADS_DIR=uploads UPLOADS_DIR=uploads
# Google Tag Manager: ID container preso da tagmanager.google.com (accanto al nome container).
# Formato GTM-XXXXXXX. Lascia vuoto per non caricare GTM (es. in locale/staging).
GTM_ID=GTM-XXXXXXX
+13 -5
View File
@@ -1,8 +1,16 @@
node_modules/ node_modules
dist/ dist/
.astro/ .astro/
.env .env
data/*.db* .env.bak*
uploads/* **/data/*.db*
!uploads/.gitkeep **/uploads/*
assets-src/ !**/uploads/.gitkeep
**/assets-src/
# Artefatti di lavoro dei subagenti (ledger, brief, review package)
.superpowers/
# Cache di Python generata rieseguendo l'oracolo
__pycache__/
*.pyc
+186 -94
View File
@@ -1,145 +1,237 @@
# InsanityLab Website # InsanityLab Website
Sito vetrina per InsanityLab con blog integrato e form di contatti. Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti, piattaforme riservate ai clienti abilitati e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**.
## Descrizione ## Descrizione
Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 7** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages. Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor rich‑text, e il form di contatti invia email tramite SMTP.
## Stack Tecnologico Accanto alla vetrina vivono due sezioni riservate, raccolte sotto `/piattaforme` e visibili solo agli utenti abilitati: il **Biohacking Campus** (guide di studio generate da un progetto esterno) e **Stress Index** (piattaforma di monitoraggio HRV). Quest'ultima è l'unica parte del sito che usa React, sotto forma di isole limitate ai grafici e alle viste interattive.
- **Framework**: Astro 7 ## Stack tecnologico
- **Runtime**: Node.js (adapter standalone)
- **Database**: SQLite (better-sqlite3) - **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
- **Autenticazione**: bcryptjs - **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone)
- **Email**: Nodemailer - **Database**: SQLite via `better-sqlite3`
- **Markup Editor**: TipTap (rich text) - **Autenticazione**: sessioni con password hashate (`bcryptjs`)
- **Font System**: @fontsource (Montserrat, Open Sans) - **Email**: Nodemailer (SMTP)
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati) - **Editor articoli**: TipTap (rich text)
- **Testing**: Vitest - **Isole interattive**: React 19 via `@astrojs/react`, solo nella piattaforma Stress Index
- **Grafici**: Recharts (caricati unicamente sulle pagine della piattaforma)
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
- **Test**: Vitest
- **Linguaggio**: TypeScript - **Linguaggio**: TypeScript
- **Deploy**: Docker (multi‑stage, `node:22-slim`) + Traefik su VPS
## Installazione ## Funzionalità principali
- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi".
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
- **Blog** con editor TipTap, immagini caricate, firma autore, proprietà degli articoli per ruolo, filtro per tematica senza ricaricare la pagina e conteggio degli articoli per categoria.
- **Cestino degli articoli**: l'eliminazione sposta in `/admin/cestino`, da dove si ripristina o si cancella in via definitiva.
- **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `piattaforme`), creazione su pagina dedicata e password impostabile a mano o generata.
- **Piattaforme riservate** (`/piattaforme`): indice delle sezioni accessibili ai soli utenti abilitati — Biohacking Campus e Stress Index.
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
## Installazione (sviluppo)
### Prerequisiti ### Prerequisiti
- Node.js 18+ e npm - Node.js 18+ e npm
### Setup ### Setup
```bash ```bash
# Installa dipendenze
npm install npm install
cp .env.example .env # poi compila i valori reali
# Copia il template di configurazione env npm run create-user -- admin <password> admin
cp .env.example .env npm run dev # http://localhost:4321
# Crea il primo utente amministratore (dopo aver configurato il DB)
npm run create-user -- admin tuapassword
``` ```
Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente. Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato automaticamente con i contenuti di default (seed) e le migrazioni idempotenti.
## Comandi Disponibili ## Comandi
| Comando | Descrizione | | Comando | Descrizione |
|---------|-------------| |---------|-------------|
| `npm run dev` | Avvia il server di sviluppo (localhost:4321) | | `npm run dev` | Server di sviluppo (localhost:4321) |
| `npm run build` | Compila il progetto per production | | `npm run build` | Build di produzione |
| `npm run preview` | Visualizza l'anteprima della build (locale) | | `npm run preview` | Anteprima locale della build |
| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) | | `npm run start` | Server di produzione (`node --env-file=.env`) |
| `npm run test` | Esegui i test con Vitest | | `npm run test` | Test con Vitest |
| `npm run create-user` | Crea un nuovo utente: `-- <nome> <password> [admin\|editor]` | | `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user\|piattaforme]` |
| `npm run sync-campus` | Rigenera i contenuti del Campus: `-- <path-progetto-biohacking-campus>` |
| `npm run seed-blog` | Carica i venti articoli divulgativi tratti dal corso base del Campus (salta quelli già presenti) |
## Gestione Contenuti ## Gestione contenuti
I testi e le immagini del sito vetrina sono contenuti taggati, modificabili senza toccare il codice tramite il pannello `/admin/content`. Ogni blocco di contenuto è identificato da un tag (ad esempio `home.hero.title`) e può essere aggiornato nel valore, negli stili predefiniti (dimensione, peso, colore) e — per le immagini — sostituito con un upload o riportato all'originale. I testi e le immagini della vetrina sono **blocchi taggati** salvati nella tabella `content_blocks`. Ogni blocco ha un tag stabile (es. `home.hero.title-line-1`), un valore, eventuali stili predefiniti, un tipo (`text` / `html` / `image`) e un **GUID** univoco.
Il pannello raggruppa i tag per pagina, offre una ricerca per tag o contenuto e mostra per ogni blocco l'ultima modifica effettuata. Visitando il sito da utente autenticato con il parametro `?annotate=1` (link "Vedi sito con tag" nel pannello), ogni contenuto taggato viene evidenziato con un badge che rimanda direttamente al form di modifica corrispondente. - **Pannello** `/admin/content`: i blocchi sono raggruppati per pagina, con ricerca per tag o contenuto, upload delle immagini e ripristino all'originale.
- **Modifica inline**: attivando "Mostra tag" dal menu utente, ogni blocco mostra un badge (nome tag + matita) che apre una finestra di modifica direttamente in pagina.
- **Componenti** `<T>` e `<TImg>`: rendono i blocchi con fallback automatico al seed quando un tag non è ancora nel database.
Sono previsti due ruoli utente: `admin` ha accesso completo (articoli del blog, contenuti, upload), mentre `editor` può operare esclusivamente sul pannello contenuti. Gli utenti si creano con `npm run create-user -- <nome> <password> editor`. Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui contenuti già esistenti (rinomine, testi aggiornati) si applicano tramite **migrazioni idempotenti** in `src/lib/db.ts`, così da non sovrascrivere le modifiche fatte dal pannello.
## Struttura Progetto > Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`).
## Blog
Gli articoli stanno nella tabella `posts` e si gestiscono da `/admin`. La pagina pubblica `/blog` filtra per tematica e pagina senza ricaricare — si scarica la stessa pagina e si sostituiscono soltanto griglia e filtri — mantenendo indirizzi veri e cronologia del browser: senza JavaScript la navigazione funziona come prima.
L'eliminazione di un articolo non cancella nulla: valorizza `deleted_at` e lo sposta nel **cestino** (`/admin/cestino`), da dove si ripristina o si elimina davvero. Ogni lettura pubblica — elenco, recenti, archivio per mese, pagina del singolo articolo — esclude il cestino, e chi non ha ruolo di gestione vede nel cestino soltanto i propri articoli.
Lo script `npm run seed-blog` carica venti articoli divulgativi ricavati dal corso base del Campus, dieci tecnici e dieci educativi. È versionato apposta: il database di produzione è un volume del VPS e il deploy non lo tocca, quindi articoli creati a mano in locale resterebbero in locale. Lo script salta gli slug già presenti e crea, se mancano, i due utenti che firmano gli articoli — la firma pubblica è lo `username` dell'autore.
## Ruoli e accessi
Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
| Ruolo | Può fare |
|-------|----------|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), piattaforme |
| `superuser` | Pannello contenuti + blog |
| `user` | Solo i **propri** articoli del blog |
| `piattaforme` | Solo le sezioni riservate (`/piattaforme`, `/campus`); nessun accesso al pannello |
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, l'indice delle piattaforme per il ruolo `piattaforme`. La gestione utenti (`/admin/users`, solo admin) consente creazione su pagina dedicata (`/admin/users/new`), cambio password — scelta a mano o generata, mostrata una sola volta perché nel database resta il solo hash — cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
> Il ruolo `campus` è stato rinominato in `piattaforme` quando le sezioni riservate sono diventate due. Una migrazione in `src/lib/db.ts` aggiorna da sé gli utenti esistenti.
## Piattaforme riservate
Le sezioni riservate sono raccolte sotto `/piattaforme`, che è anche la pagina di atterraggio del ruolo omonimo. L'elenco vive in `src/data/piattaforme.ts`: aggiungerne una è una riga, e il menu dell'header la mostra da sé a chi ha i permessi.
### Biohacking Campus (`/campus`)
La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare all'indice delle piattaforme — ma usano i font, i colori e il CSS globale di InsanityLab.
I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`.
Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida.
> Il progetto sorgente sta in `/home/adriano/Wasabi-Adp-Work/AI-OS/projects/personale/biohacking-campus` (percorso predefinito dello script). In sviluppo, dopo un sync, il server va riavviato: `src/lib/campus.ts` tiene l'indice in cache in memoria e le guide nuove darebbero 404.
### Stress Index (`/piattaforme/stress-index`)
Piattaforma di monitoraggio HRV portata dentro il sito da un prototipo Next.js esterno: nove viste — cruscotto giornaliero, elenco clienti, scheda cliente, analytics di studio, modulo sport con sessioni e atleti, team live, impostazioni, organizzazione. Il design system del prototipo (Tailwind, palette teal) è stato sostituito con quello del sito: lo stile sta in `src/styles/stress-index.css`, un foglio globale prefissato `.si` perché le stesse classi servono sia ai componenti Astro sia alle isole React, che lo scope di Astro non raggiungerebbe.
React è limitato a ciò che ha davvero bisogno di stato: la scheda cliente, la vista Analytics, il modulo Sport e i grafici Recharts. Il resto è Astro con qualche riga di JavaScript. I dati sono **dimostrativi** e stanno in `src/lib/stress-index/data.ts`; le aggregazioni con una logica propria (finestre temporali, distribuzioni, riepiloghi atleta) vivono in `analytics.ts` e `sport.ts` accanto, coperte da test.
## Struttura del progetto
``` ```
insanitylab-website/ insanitylab-website/
├── src/ ├── src/
│ ├── pages/ # Pagine Astro (routing automatico) │ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus, /piattaforme)
│ ├── components/ # Componenti Astro riutilizzabili │ ├── components/ # Componenti (home/, content/, stress-index/, Header, Footer, …)
│ ├── layouts/ # Layout master │ ├── layouts/ # Layout Base, Admin, Campus, StressIndex
│ ├── utils/ # Funzioni di utilità │ ├── data/ # Dati statici + seed contenuti (services, trainings, site, piattaforme, content-seed)
│ ├── env.d.ts # Type definitions globali │ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env, stress-index/
│ └── ... │ ├── assets/img/ # Immagini ottimizzate da Astro
├── data/ # Database SQLite e dati persistenti │ └── styles/ # global.css, stress-index.css
│ └── insanitylab.db # (generato al primo avvio) ├── data/ # SQLite (volume in produzione)
├── uploads/ # Directory per upload immagini e file ├── uploads/ # Upload immagini (volume in produzione)
├── dist/ # Output build (server) ├── campus-content/ # Contenuti del Campus generati da sync-campus
├── tests/ # Test suite ├── scripts/ # create-user, sync-campus, seed-blog-biohacking, smoke test
├── astro.config.mjs # Configurazione Astro ├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
├── tsconfig.json # Configurazione TypeScript ├── compose.yaml # Stack Docker + label Traefik
├── vitest.config.ts # Configurazione test framework ├── Dockerfile # Build multi-stage
├── .env.example # Template variabili di ambiente ├── .env.example # Template variabili d'ambiente
├── .env # Variabili di ambiente (non versionato) └── README.md
├── package.json # Dipendenze e script npm
└── README.md # Questo file
``` ```
## Configurazione Ambiente ## Variabili d'ambiente
Il file `.env` richiede le seguenti variabili: Il file `.env` (non versionato) richiede:
- `SMTP_HOST`: Host SMTP per invio email | Variabile | Descrizione |
- `SMTP_PORT`: Porta SMTP (solitamente 587) |-----------|-------------|
- `SMTP_USER`: Utente SMTP | `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) |
- `SMTP_PASS`: Password SMTP | `SMTP_PORT` | Porta SMTP (`465` con SSL) |
- `CONTACT_TO`: Email di destinazione contatti | `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) |
- `CONTACT_FROM`: Email mittente | `SMTP_PASS` | Password della casella |
- `DB_PATH`: Percorso database SQLite | `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) |
- `UPLOADS_DIR`: Cartella upload file | `CONTACT_TO` | Destinatario dei messaggi del form |
| `DB_PATH` | Percorso del database SQLite |
| `UPLOADS_DIR` | Cartella degli upload |
| `CAMPUS_DIR` | Cartella dei contenuti del Campus (default `./campus-content`) |
## Development Workflow La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
1. **Avvia il server locale**: `npm run dev` ## Deploy in produzione
2. **Modifica file** in `src/` (hot reload automatico)
3. **Test**: `npm run test`
4. **Build**: `npm run build` (verifica errori di build)
5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione)
## Deployment Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/insanitylab/` (repo in `src/`, volumi `data/` e `uploads/`). Aggiornamento:
```bash ```bash
# Build per production cd /opt/docker/insanitylab/src
npm run build sudo git -c safe.directory="$PWD" pull origin main # il repo è di root: serve sudo, con password
docker compose up -d --build # docker gira senza sudo
# Opzionalmente, preview locale
npm run preview
# In production, il server parte con:
npm run start
# Variabili ambiente in production vanno impostate sull'hosting
# (es. Hostinger VPS, Vercel, etc.)
``` ```
## Riferimenti Tecnici Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello. Le migrazioni che cambiano un valore sono sempre guardate dal valore precedente, quindi non sovrascrivono ciò che è stato modificato dal pannello — con la conseguenza, da tenere presente, che un contenuto già ritoccato a mano **non** riceve il nuovo default: va corretto dal pannello.
Per dettagli su architettura, schema database, componenti e specifiche vedi: Prima di ogni deploy conviene fare il backup del database, che è in modalità WAL: un `cp` del solo `.db` perderebbe le modifiche non ancora consolidate.
- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md`
## Problemi Comuni ```bash
sqlite3 -cmd ".timeout 10000" /opt/docker/insanitylab/data/insanitylab.db ".backup '/dest/insanitylab.db'"
sqlite3 /dest/insanitylab.db "PRAGMA integrity_check;" # deve rispondere 'ok'
```
### `better-sqlite3` fallisce su npm install Gli articoli del blog non viaggiano con il codice: per caricarli in produzione, una volta sola, `docker exec insanitylab node scripts/seed-blog-biohacking.mjs`.
Se `better-sqlite3` non compila, assicurati di avere build tools installati: ### Dominio e DNS
- **Linux**: `sudo apt-get install build-essential python3`
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: Visual Studio Build Tools o MinGW
### Database locked - Dominio ufficiale: **insanitylab.it** (canonico l'apex; `www` fa redirect 301 all'apex tramite Traefik).
- I record **A** di `@` e `www` puntano al VPS; la **posta resta su Aruba** (MX, SPF, webmail non vengono toccati).
- TLS Let's Encrypt automatico via Traefik (certresolver `mytlschallenge`).
Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`. > Attenzione dietro Traefik: `allowedDomains` in `astro.config.mjs` deve elencare i domini serviti, altrimenti Astro ignora `X-Forwarded-Proto/Host` e il controllo CSRF respinge i POST con 403.
## Problemi comuni
**`better-sqlite3` non compila su `npm install`** — servono i build tools:
- Linux: `sudo apt-get install build-essential python3`
- macOS: `xcode-select --install`
- Windows: Visual Studio Build Tools
**Modifiche allo `<style>` scoped di `[slug].astro` non compaiono in dev** — l'HMR di Astro 7 non sempre ricompila lo stile scoped delle route dinamiche: riavviare il dev server (`astro dev stop` + `npm run dev`).
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
**Le guide nuove del Campus danno 404 in sviluppo** — `src/lib/campus.ts` tiene l'indice in cache in memoria: dopo `npm run sync-campus` va riavviato il server.
**Il database di produzione sembra non aggiornato dopo il deploy** — `sqlite3 -readonly` non legge il file `-wal`, dove stanno le modifiche non ancora consolidate, e mostra valori vecchi. Per verificare, interrogare il database in lettura‑scrittura oppure — meglio — guardare direttamente le pagine del sito.
## Licenza ## Licenza
Proprietario InsanityLab. Proprietà di InsanityLab.
## Deploy (dopo la separazione del 03/09/2026 e i due repo del 29/09/2026)
Dal 29/09/2026 questo repo contiene **solo il sito**, in radice. Le aree riservate hanno il
loro repo, `gitea:Adriano/InsanityLab-platforms` (issue #20); fino ad allora le due app erano
`apps/sito` e `apps/platforms` dello stesso repo.
Il progetto compose si chiama **`insanitylab`** (`name:` esplicito nel compose), non piu'
`src` come quando la cartella era `/opt/docker/insanitylab/src`.
```
cd /opt/docker/insanitylab/src
docker compose build # il sito resta in piedi: non tocca il container
docker compose up -d # sostituisce il container
```
⚠️ **`.env` non e' in git** (e non deve esserlo): resta dov'e' ed e' l'unico file che un
`git merge` non porta con se'. Il 04/09/2026 lo spostamento sotto `apps/sito/` ha lasciato
`.env` alla vecchia radice, l'`up` e' fallito **dopo** che il `down` aveva gia' tolto il
container, e il sito e' rimasto giu' **9,7 secondi**. ⚠️ Il 29/09/2026 il sito torna in radice:
al primo deploy dopo quel cambio `.env` va spostato a mano da `apps/sito/.env` a `.env`.
Prima di ogni swap:
```
docker compose config >/dev/null # legge env_file e fallisce SUBITO se manca qualcosa
```
⚠️ Un `docker run` sull'immagine **non** e' una prova sufficiente del deploy: non legge
`env_file`, quindi passa anche quando il compose fallirebbe.
+20 -2
View File
@@ -1,18 +1,36 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import node from '@astrojs/node'; import node from '@astrojs/node';
import react from '@astrojs/react';
export default defineConfig({ export default defineConfig({
site: 'https://insanitylab.tielogic.xyz', site: 'https://insanitylab.it',
output: 'server', output: 'server',
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora // Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403). // X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
// sottodominio tielogic.xyz redirigono all'apex a livello Traefik.
security: { security: {
allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }], allowedDomains: [
{ hostname: 'insanitylab.it', protocol: 'https' },
{ hostname: 'www.insanitylab.it', protocol: 'https' },
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
],
}, },
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL. // La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
redirects: { redirects: {
'/programs': { status: 301, destination: '/services' }, '/programs': { status: 301, destination: '/services' },
'/programs/[slug]': { status: 301, destination: '/services/[slug]' }, '/programs/[slug]': { status: 301, destination: '/services/[slug]' },
// Modifiche 02: "Pilates Flow" → "Profilazione". "Rehab" esce dalla griglia Servizi e
// diventa un riquadro Training, ma la pagina dettaglio resta su /services/rehab.
'/services/pilates-flow': { status: 301, destination: '/services/profilazione' },
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
}, },
integrations: [react()],
}); });
+28 -1
View File
@@ -1,3 +1,14 @@
# ⚠️ `name:` esplicito. Fino al 03/09/2026 il progetto compose si chiamava `src`, dedotto
# dalla cartella /opt/docker/insanitylab/src. Con lo spostamento sotto apps/sito la cartella
# cambia nome e il progetto dedotto diventerebbe `sito`: per docker sarebbe un progetto NUOVO,
# e il container `insanitylab` gia' in piedi risulterebbe di un altro — con il nome occupato.
# Al primo deploy dopo la separazione va fermato il progetto vecchio PRIMA di alzare questo:
# docker compose -p src -f <vecchio compose> down
# Poi da qui: docker compose up -d --build
# Dal 29/09/2026 il sito e' di nuovo in radice (/opt/docker/insanitylab/src): il `name:`
# esplicito tiene fermo il progetto `insanitylab` anche a questo cambio di cartella.
name: insanitylab
services: services:
insanitylab: insanitylab:
build: . build: .
@@ -16,11 +27,27 @@ services:
- /opt/docker/insanitylab/uploads:/app/uploads - /opt/docker/insanitylab/uploads:/app/uploads
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.tielogic.xyz`) # Router principale: solo il dominio ufficiale canonico (apex insanitylab.it)
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.it`)
- traefik.http.routers.insanitylab.tls=true - traefik.http.routers.insanitylab.tls=true
- traefik.http.routers.insanitylab.entrypoints=websecure - traefik.http.routers.insanitylab.entrypoints=websecure
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
- traefik.http.services.insanitylab.loadbalancer.server.port=4321 - traefik.http.services.insanitylab.loadbalancer.server.port=4321
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
# ⚠️ `insanitylab.tielogic.xyz` tolto dal DNS da Adriano il 03/09/2026: il record non
# esiste piu'. Lasciarlo qui non e' inerte — il router chiede un certificato per i
# nomi della sua regola, e una sfida ACME su un nome che non risolve fallisce a ogni
# tentativo, che e' esattamente cio' che Let's Encrypt limita. Il vecchio dominio ha
# smesso di essere raggiungibile: da qui in poi vale solo il redirect di `www`.
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`)
- traefik.http.routers.insanitylab-redir.tls=true
- traefik.http.routers.insanitylab-redir.entrypoints=websecure
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
- traefik.http.routers.insanitylab-redir.service=insanitylab
- traefik.http.routers.insanitylab-redir.middlewares=insanitylab-tocanon
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.regex=^https?://[^/]+/(.*)
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.replacement=https://insanitylab.it/$${1}
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.permanent=true
networks: networks:
- traefik - traefik
+169
View File
@@ -0,0 +1,169 @@
# Articoli del blog — insanity.lab
Elenco completo degli articoli presenti sul sito, con categoria, stato di pubblicazione e sintesi.
Documento generato il 1 agosto 2026 per la revisione del cliente.
**Totale:** 20 articoli attivi (20 pubblicati, 0 in bozza).
## Articoli pubblicati
### 1. Il sonno si misura in cicli, non in ore
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 31 luglio 2026
- **Link:** https://insanitylab.it/blog/sonno-cicli-non-ore
Sette ore spezzate valgono meno di sei ore intere. Come funziona l'architettura del sonno e perché conviene ragionare in blocchi da novanta minuti.
### 2. HRV: che cosa dice davvero la variabilità cardiaca
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 29 luglio 2026
- **Link:** https://insanitylab.it/blog/hrv-variabilita-cardiaca
Non è la frequenza cardiaca e non è un voto sulla forma fisica. È la misura di quanto il sistema nervoso sa attivarsi e, soprattutto, tornare in equilibrio.
### 3. Quante proteine servono davvero
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 27 luglio 2026
- **Link:** https://insanitylab.it/blog/quante-proteine-servono
Prima la quantità, poi la qualità, solo alla fine distribuzione e timing. La gerarchia che conta, con i numeri per chi si allena e per chi no.
### 4. Creatina: che cosa dice la ricerca
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 25 luglio 2026
- **Link:** https://insanitylab.it/blog/creatina-cosa-dice-la-ricerca
È l'integratore più studiato in assoluto e uno dei pochi con prove solide. A cosa serve, quanta prenderne e perché non è una sostanza dopante.
### 5. Il freddo e l'ormesi: perché un piccolo stress fa bene
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 23 luglio 2026
- **Link:** https://insanitylab.it/blog/freddo-ormesi
Vasi sanguigni, grasso bruno, noradrenalina. Che cosa accade nell'organismo durante l'esposizione al freddo e dove sta il confine con l'esagerazione.
### 6. Respirare meno per ossigenare meglio: il ruolo della CO2
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 21 luglio 2026
- **Link:** https://insanitylab.it/blog/respirazione-anidride-carbonica
Non è l'ossigeno a mancare, ma l'anidride carbonica a servire. Come funziona lo scambio nei tessuti e perché la respirazione nasale cambia le cose.
### 7. La luce che regola l'orologio biologico
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 19 luglio 2026
- **Link:** https://insanitylab.it/blog/luce-orologio-biologico
Un segnale nella luce del mattino fa partire la giornata metabolica. E gli schermi della sera raccontano al cervello che è ancora mezzogiorno.
### 8. Recupero attivo: perché fermarsi non basta
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 17 luglio 2026
- **Link:** https://insanitylab.it/blog/recupero-attivo
Il divano è riposo, non recupero. Sistema linfatico, tessuto connettivo e la differenza fra togliere il carico e favorire la rigenerazione.
### 9. VO2max: l'indicatore che parla di longevità
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 15 luglio 2026
- **Link:** https://insanitylab.it/blog/vo2max-longevita
Quanta energia sai erogare nel tempo. È fra i parametri più correlati alla salute futura, e si allena a qualsiasi età.
### 10. Leggere i dati di orologi e anelli senza farsi ingannare
- **Categoria:** Articoli tecnici & scientifici
- **Data:** 13 luglio 2026
- **Link:** https://insanitylab.it/blog/leggere-i-dati-dei-dispositivi
Un numero isolato non dice quasi nulla. Le tre regole per trasformare i dati di un dispositivo indossabile in decisioni sensate.
### 11. Sette abitudini per dormire meglio da stasera
- **Categoria:** Contenuti educativi
- **Data:** 11 luglio 2026
- **Link:** https://insanitylab.it/blog/dormire-meglio-da-stasera
Nessun integratore, nessuna spesa. Piccoli gesti che spostano davvero la qualità del riposo, da provare uno alla volta.
### 12. Muoversi ogni giorno: quanto basta davvero
- **Categoria:** Contenuti educativi
- **Data:** 09 luglio 2026
- **Link:** https://insanitylab.it/blog/muoversi-ogni-giorno
Fra il divano e la maratona c'è tutto lo spazio che serve. Come mettere insieme una settimana di movimento sostenibile, anche partendo da zero.
### 13. Il peso non si decide sulla bilancia
- **Categoria:** Contenuti educativi
- **Data:** 07 luglio 2026
- **Link:** https://insanitylab.it/blog/il-peso-non-e-solo-la-bilancia
Cinque pilastri in ordine di importanza, e perché il lavoro vero avviene mentre dormiamo.
### 14. Proteine a tavola: come distribuirle nella giornata
- **Categoria:** Contenuti educativi
- **Data:** 05 luglio 2026
- **Link:** https://insanitylab.it/blog/proteine-a-tavola
Senza bilancia e senza calcoli complicati. Quantità pratiche, fonti quotidiane e l'errore più comune: concentrare tutto a cena.
### 15. Doccia fredda: come iniziare senza esagerare
- **Categoria:** Contenuti educativi
- **Data:** 03 luglio 2026
- **Link:** https://insanitylab.it/blog/doccia-fredda-come-iniziare
Una progressione in quattro settimane per abituarsi al freddo, con le avvertenze da tenere presenti prima di cominciare.
### 16. Cinque minuti di respiro al giorno
- **Categoria:** Contenuti educativi
- **Data:** 01 luglio 2026
- **Link:** https://insanitylab.it/blog/cinque-minuti-di-respiro
Un esercizio semplice da fare ovunque per abbassare la tensione, e come usarlo prima di dormire o prima di una situazione difficile.
### 17. La luce del mattino è la sveglia naturale
- **Categoria:** Contenuti educativi
- **Data:** 29 giugno 2026
- **Link:** https://insanitylab.it/blog/luce-del-mattino
Dieci minuti all'aperto appena alzati cambiano il sonno della notte successiva. E la sera, qualche accorgimento con gli schermi.
### 18. Come capire quando serve fermarsi
- **Categoria:** Contenuti educativi
- **Data:** 27 giugno 2026
- **Link:** https://insanitylab.it/blog/quando-fermarsi
I segnali che distinguono la stanchezza normale da quella che chiede una pausa, e cosa fare nei giorni di scarico.
### 19. Il piatto della longevità
- **Categoria:** Contenuti educativi
- **Data:** 25 giugno 2026
- **Link:** https://insanitylab.it/blog/il-piatto-della-longevita
Un modello visivo al posto delle tabelle: metà verdure, un quarto carboidrati, un quarto proteine. Semplice da ricordare, difficile da sbagliare.
### 20. Tenere il cervello in allenamento
- **Categoria:** Contenuti educativi
- **Data:** 23 giugno 2026
- **Link:** https://insanitylab.it/blog/cervello-in-allenamento
Movimento, sonno, relazioni e cose nuove da imparare: le abitudini che proteggono la mente invecchiano meglio di qualsiasi integratore.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,101 +0,0 @@
# Inventario contenuti — pagine vetrina (2026-07-05)
Documento di riferimento per il piano `2026-07-05-contenuti-taggati-pannello-editor.md`. Elenca, file per file, i testi visibili e le immagini da estrarre nel sistema di tag. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione; qui compaiono abbreviati per identificazione.
**Nota nomenclatura**: i nomi tag canonici sono quelli nella sezione "Interfaces → Produces" dei task del piano (segmenti kebab-case, es. `home.hero.title-line-1`); dove questo inventario usa camelCase va convertito.
## Pagine
### `src/pages/index.astro` (home)
Solo orchestrazione componenti. Hardcoded: `home.meta.title` ("Performance. Balance. Longevity.", prop title di Base), `home.training.title` ("Training"), `home.training.subtitle` ("Porta i tuoi allenamenti al livello successivo: trova il percorso…"). Nessuna immagine diretta.
### `src/pages/about.astro`
Meta (2): `about.meta.title` ("Insanitylab"), `about.meta.description` ("Mission, storia, team e metodo…"). Hero: `about.hero.title` ("Insanitylab", via PageHero).
Sezioni testo: mission (title + body), direzione (title + body + cta "Scopri"), approccio (title + body + cta "Contattaci"), about (title + body), convenzioni (title + body + item1-3 in `<ul class="conv">` + riga finale con `{site.email}` · `{site.phone}` interpolati → spezzare in `about.convenzioni.cta-text` + tag global), team (title + body + staff-intro), metodo (title + body + closing), studios (title + body1-3), vision (title + body1-2).
Immagini (glob, alt hardcoded): `about.mission.image` (about-training-group.jpg), `about.about.image` (about-pushup.jpg), `about.convenzioni.image` (about-conventions.jpg), `about.metodo.image` (about-team-group.jpg), `about.studios.image` (about-studios.jpg), `about.vision.image` (about-vision.jpg). Griglia team: 9 foto da `imageAbout` (tag `team.<slug>.image-about`).
### `src/pages/contact.astro`
`contact.meta.title` ("Contatti"), `contact.meta.description`, `contact.hero.title` ("Contattaci"), `contact.hero.image` (contact-hero.jpg, background PageHero), `contact.map.button` ("Carica la mappa (Google Maps)"), `contact.map.note` ("La mappa viene caricata da Google solo dopo il tuo consenso."), label info (address-label "Indirizzo", phone-label "Telefono", email-label "E-mail" — i valori sono i tag `global.contact.*`), `contact.invite` ("Per informazioni o per richiedere una consulenza iniziale…"). Iframe Maps generato via JS (title tecnico, escluso).
### `src/pages/training.astro`
`training.meta.title`, `training.meta.description`, `training.hero.title` ("training"), `training.intro.title` ("Training"), `training.intro.subtitle` ("Porta i tuoi allenamenti al livello successivo: programmi completi…" — variante diversa dalla home), `training.trial.title` ("Prenota la lezione di prova"), `training.trial.body`, `training.trial.cta` ("Prenota"), `training.trial.image` (trial-class.jpg). Blocchi per-training: testi dai tag `training.<id>.*` (vedi trainings.ts); nota `<h2>{t.title.toLowerCase()}</h2>` (lowercase a runtime, conservare).
### `src/pages/404.astro`
`notfound.meta.title`, `notfound.eyebrow` ("Errore 404"), `notfound.title` ("Pagina non trovata"), `notfound.body`, `notfound.cta` ("Torna alla home").
### `src/pages/programs/index.astro`
`programs.meta.title`, `programs.meta.description`, `programs.hero.title` ("Programmi"), `programs.intro.title`, `programs.intro.body` ("Questi sono i programmi delle classi di Insanity Lab…"), `programs.card.more-label` ("Per saperne di più →", ripetuto). Card e immagini (`program-<slug>.jpg`, alt = title) da programs.ts.
### `src/pages/programs/[slug].astro`
Hardcoded: `program.side.categories-label` ("Categorie"), `program.side.category.1-4` ("Fitness", "Salute", "Stile di vita", "Nutrizione"), `program.side.others-label` ("Altri programmi"), `program.pricing.empty-note` (html con link "Contattaci per i dettagli… Scrivici →"), `program.pricing.per` ("al mese, semestrale"), `program.pricing.entries-singular/-plural` ("ingresso settimanale"/"ingressi settimanali"), `program.pricing.buy-cta` ("Acquista").
Dinamici: meta = title/excerpt del programma; hero = `title.toUpperCase()` (conservare la trasformazione); corpo/features/faq/pricing da data. Immagine `program-<slug>.jpg` come hero bg e inline.
## Componenti condivisi
### `Header.astro`
Logo `/img/logo-dark.png` (public) con alt "InsanityLab" (`header.logo.alt`). Nav: label da `site.navigation` → tag `global.nav.<i>.label` (5 voci: "Insanitylab", "Training", "Programmi", "Blog & Edugo", "Contatti"). Aria-label esclusi.
### `Footer.astro`
Logo light + alt; `footer.tagline` (html, "PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY."); `footer.about` ("Promuoviamo il movimento come cura di sé…"); riga contatti con `<br>` (comporre dai tag `global.contact.*`); orari (`global.hours.1/2`); titoli colonne training/programs/contact; `footer.copyright` ("Insanitylab, All Rights Reserved" — `<strong>` e anno dinamico restano fuori dal tag); `footer.social-label` ("Seguici"); label link da `site.footerTraining` (4) e `site.footerPrograms` (6) → `footer.training.<i>.label`, `footer.programs.<i>.label`.
### `ContactForm.astro`
Placeholder: first-name "Nome", last-name "Cognome" (solo full), phone "Telefono" (solo full), email "E-mail", message "Messaggio". Submit "Invia messaggio". Messaggi nello `<script>` client (passare via data-*): success "Messaggio inviato, ti ricontatteremo al più presto.", error-generic "Errore di invio, riprova più tardi.", error-network "Errore di rete, riprova più tardi.".
### `Agenda.astro` + `agenda.ts`
`agenda.title` ("Agenda"); da agenda.ts: `agenda.discipline.<i>` (7: Events, Crossfit, Cardio, Box, Meditation, Yoga Classes, Body Balance), `agenda.day.<i>` (7: Lunedì…Domenica), `agenda.slot.<i>` (5: 10.00…20.00). **Celle (28 voci discipline/time/trainer) ESCLUSE**: dati placeholder Prowess, in attesa orario reale dal cliente.
### `PageHero.astro`
Nessun testo proprio (props title/eyebrow/bgImage passate dalle pagine con i rispettivi tag). Per bgImage da override serve accettare anche URL stringa.
## Componenti home
### `HeroSlider.astro`
`home.hero.title-line-1/-2/-3` ("PERFORMANCE." / "BALANCE." / "LONGEVITY."), `home.hero.cta` ("Scopri"), `home.hero.image` (hero-1.png, alt "Atleti InsanityLab"). Frecce slider: aria, escluse.
### `InfoColumns.astro`
3 colonne: `home.info.col<n>.title` ("PERFORMANCE." / "BALANCE." / "LONGEVITY.") + `home.info.col<n>.image` (info-performance/balance/longevity.jpg, background via getImage → usare `contentImageUrl`).
### `FeatureBlocks.astro`
3 blocchi × (title, body, cta "Scopri") → `home.feature.<performance|balance|longevity>.title/.body/.cta` + `.image` (feature-*.jpg, alt = title).
### `QuoteBanner.astro`
`home.quote.text` (html — congelare nel seed l'output della regex che avvolge le parole chiave in `<strong>`, poi rimuovere la regex), `home.quote.cite` ("Insanitylab"), `home.quote.image` (quote-bg.jpg, background). Testo renderizzato 2× per il marquee (seconda copia aria-hidden).
### `TrainingCards.astro`
`home.training-cards.link-label` ("Scopri →"). Card: testi/immagini dai tag `training.<id>.*`. Usato in home e in training.astro.
### `ProgramsGrid.astro`
`home.programs.title` ("Programmi"), `home.programs.body` (uguale a programs.intro.body ma tag separato). Griglia da programs.ts + SVG da program-icons.ts (icone escluse dal tagging).
### `TeamSection.astro`
`home.team.title` (html, "Team<br>Trainer"), `home.team.cta` ("Scopri team"). Membri: filtro `homeTeam` su team.ts, foto `team.<slug>.image`, figcaption = shortName.
### `MethodSteps.astro`
`home.method.title` (html, "Il metodo<br>InsanityLab"). Intro/note/steps dai tag `method.*` (intro html con `<strong>` congelato, rimuovere il set:html con wrapping runtime). Immagini step alt="" (decorative).
### `PartnersStrip.astro`
Solo loghi: `partner.<i>.name` (alt) + `partner.<i>.image` da partners.ts.
## Componente about
### `about/Timeline.astro`
`about.storylab.title` ("Storylab"), `about.storylab.body`, `about.timeline.label` ("INSANITYLAB 2021-2024"), `about.timeline.open-text`, `about.timeline.stage<1-3>.label` ("INSANITYLAB 2025"/"2026"/"OGGI") + `.text` (placeholder "Contenuto in arrivo…" — testi Storylab attesi dal cliente). Struttura `<details>/<summary>` da conservare.
## Data file
- **site.ts**: phone "351 7535977", email "info@insanitylab.it", address "Via Leandro Alberti, 76", cityLine "40139 Bologna", hours (2), socials (3, label+url — url restano nel data file), navigation (5), footerTraining (4), footerPrograms (6).
- **team.ts**: 9 membri (slug da shortName lowercase): donata, nicola, eva (founder); alessandro, eleonora, antonello, alice, davide, giulia. Campi: name, role, bio → `team.<slug>.name/.role/.bio`; immagini: `image` (4: donata, nicola, eleonora, antonello) e `imageAbout` (tutti e 9, file `team-*-dark`).
- **programs.ts**: 6 slug: personal-training, performance-class, coaching, pilates-flow, rehab, nutrition. Campi: title, subtitle, excerpt, longDescription, features[] (4-5), faq[] (3-6 coppie q/a). Solo performance-class ha hasPricing. → `program.<slug>.title/.subtitle/.excerpt/.long-description/.feature.<i>/.faq.<i>.q/.a`.
- **pricing.ts**: heading "Acquista la tua performance class", intro, 3 gruppi (label; solo il gruppo 1 "semestrale" ha 3 plans). Per plan: monthly/entries (numerici, restano nel data file), note, installments, best → `pricing.group.1.plan.<i>.note/.installments/.best`.
- **method.ts**: intro (html), extraNote, 4 step (title, text, image method-step-1..4) → `method.intro`, `method.extra-note`, `method.step.<n>.title/.text/.image`.
- **trainings.ts**: 4 id: one-to-one, small-groups, performance-class, fit-remote. Campi title, caption, description, cta.label (href resta) + image → `training.<id>.title/.caption/.description/.cta-label/.image`.
- **partners.ts**: 4: "Ticket Welfare Edenred", "Welbee", "AON", "Howden" → `partner.<i>.name/.image`.
- **program-icons.ts**: path SVG, esclusi dal tagging.
## Note trasversali
1. Tag `type: 'html'` necessari per: footer.tagline (`&nbsp;`), home.team.title e home.method.title (`<br>`), home.quote.text e method.intro (`<strong>` congelato), program.pricing.empty-note (link `<a>`).
2. Trasformazioni runtime da conservare nel template (non nel valore): `.toLowerCase()` (training h2), `.toUpperCase()` (hero dettaglio programma), anno copyright.
3. Duplicazioni intenzionali (tag distinti, valori oggi identici): programs.intro.body / home.programs.body / pricing.intro; home.training.subtitle / training.intro.subtitle (varianti).
4. Loghi header/footer stanno in `public/img/` (non passano dalla pipeline asset): TImg non applicabile — per l'override basta `contentImageUrl(tag, '/img/logo-dark.png')` su `<img>`.
5. Conteggi indicativi: ~150 tag testo da template, ~200 da data file, ~60 slot immagine.
@@ -1,130 +0,0 @@
# Sito web InsanityLab — Design
**Data:** 2026-07-01
**Dominio di destinazione:** insanitylab.tielogic.xyz (VPS Hostinger, porting in fase successiva)
**Stato:** approvato dall'operatore durante la sessione di brainstorming
## Obiettivo
Realizzare il sito vetrina di InsanityLab (centro di allenamento e benessere, Bologna) a partire dal design Figma fornito dal designer (`FigmaDoc/*.pdf`, `Web Insanity.fig`), che ristilizza il template WordPress "Prowess" (analizzato in `Dati/cassetta-me-analisi.md`) con una palette beige/marrone al posto del rosso originale. Il sito include un blog gestibile dal cliente tramite pannello protetto da login e un form contatti che invia email. La gestione degli acquisti online (pagina prezzi Performance Class) è rimandata a una fase successiva.
## Stack
| Livello | Scelta |
|---|---|
| Framework | Astro 5, output `server` con adapter Node |
| Pagine vetrina | Prerenderizzate (`prerender = true`) |
| Blog e admin | Server-rendered |
| Stile | CSS custom (palette estratta dai PDF Figma), nessun framework CSS |
| Font | Montserrat (titoli) + Open Sans (corpo), self-hosted |
| JS client | Vanilla, minimo indispensabile (slider, tab, accordion, header sticky) |
| Database | SQLite via `better-sqlite3`, file `data/insanitylab.db` |
| Editor admin | WYSIWYG TipTap, con upload immagini nel corpo articolo |
| Email | nodemailer → SMTP (credenziali via `.env`) |
| Test | vitest (logica) + smoke test su build |
| Versionamento | git dal primo commit, remote su GitLab |
| Deploy (fase 2) | Dockerfile multi-stage + compose, gestito poi via Komodo sul VPS |
Motivazione: sito prevalentemente statico con una sola area dinamica (blog). Un'unica applicazione Node leggera evita la manutenzione di un CMS completo, mantiene ottime prestazioni e SEO, e resta estendibile (pagamenti in fase futura).
## Route
### Pubbliche
| Route | Contenuto | Fonte design |
|---|---|---|
| `/` | Homepage: hero slider, 3 colonne Performance/Balance/Longevity, feature alternata, banner citazione, Training (4 card), Programmi (6 icone), Agenda con tab, Team trainer, Metodo (4 step), loghi partner (Ticket Welfare Edenred, Welbee, AON, Howden), footer con mini-form | Homepage.pdf |
| `/about` | Mission, direzione, approccio, about, timeline "Storylab" (2021-2024 aperta; +2025, +2026, +oggi ad accordion), convenzioni aziendali, team completo (9 persone), metodo, IN-SANITY STUDIOS, vision | Insanitylab.pdf |
| `/training` | 4 servizi (One to One, Small Groups, Performance Class, Fit Remote) con blocchi alternati, agenda, CTA "prenota la lezione di prova" | Training.pdf |
| `/programs` | Griglia 6 programmi con testi reali | Programmi.pdf |
| `/programs/[slug]` | Pagina dettaglio per ogni programma: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition` | Performance class.pdf |
| `/blog` | Blog & Edugo: intro, filtri per categoria, griglia articoli, paginazione | Blog & Edugo.pdf |
| `/blog/[slug]` | Articolo singolo con sidebar (autore, categorie, ultimi articoli, archivio) | Articolo.pdf |
| `/contact` | Form contatti (Nome, Cognome, Telefono, E-mail, Messaggio), mappa Google, recapiti | Contatti.pdf |
Dettaglio programmi: layout unico modellato su Performance class.pdf (FAQ ad accordion, descrizione, sidebar categorie + altri programmi). Il blocco pricing compare solo dove esistono prezzi — al lancio solo `performance-class` (3 piani semestrali: 130/234/300 € al mese, con varianti trimestrale e mensile ad accordion). I bottoni "acquista" puntano per ora a `/contact`. I testi degli altri cinque programmi partono dalle descrizioni di Programmi.pdf; il cliente li integrerà.
### Admin (richiedono sessione)
| Route | Funzione |
|---|---|
| `/admin/login` | Login user/password |
| `/admin` | Elenco articoli (pubblicati e bozze) |
| `/admin/new` | Nuovo articolo |
| `/admin/edit/[id]` | Modifica articolo |
### API
| Endpoint | Funzione |
|---|---|
| `POST /api/contact` | Invio email form contatti (honeypot antispam + rate-limit) |
| `POST/PUT/DELETE /api/admin/posts*` | CRUD articoli (sessione richiesta) |
| `POST /api/admin/upload` | Upload immagini (copertina e corpo articolo, salvate in `public/uploads/`) |
## Design system
- **Palette** (valori esatti da campionare dai PDF in fase di implementazione): accento beige/tan ~`#b8a98a` (bottoni, etichette), marrone scuro ~`#3a2e28` (sezioni dark: agenda, banner citazione, footer, card scure), fondi chiari `#f5f3f0`/bianco.
- **Tipografia**: Montserrat uppercase con letterspacing largo per titoli e bottoni; Open Sans per il corpo. File woff2 self-hosted (niente Google Fonts CDN, conformità GDPR).
- **Componenti chiave**: header sticky (trasparente sopra l'hero, bianco con ombra allo scroll), hero slider (scroll-snap CSS + JS vanilla), card programma testo/foto alternate, agenda settimanale con tab per disciplina, accordion FAQ e pricing, form con stati di validazione.
- **Responsive**: mobile-first; il Figma è solo desktop (1366 px), il comportamento mobile si deriva dalle convenzioni del template (menu hamburger sotto i 992 px, griglie che collassano a colonna).
- **Immagini**: estratte dall'archivio `Web Insanity.fig` (è uno ZIP con cartella `images/`; i file hanno nomi hash e vanno identificati visivamente e rinominati per sezione). Fallback: asset in `Dati/` e estrazione da PDF con `pdfimages`. Ottimizzazione con `astro:assets` (webp, lazy loading, srcset).
- **SEO**: title/description per pagina, Open Graph, sitemap, robots.txt, favicon dal logo.
- **Mappa contatti**: iframe Google Maps con click-to-load (consenso prima del caricamento, GDPR).
## Backend
### Database
SQLite in `data/insanitylab.db` (fuori da `src/`, montabile come volume Docker in fase 2). Schema creato da script SQL idempotente all'avvio.
```sql
posts (id, title, slug UNIQUE, category, excerpt, body_html,
cover, published_at, draft, created_at, updated_at)
users (id, username UNIQUE, password_hash)
sessions (token PK, user_id, expires_at)
```
Categorie blog fisse (da Figma): Articoli tecnici & scientifici, Contenuti educativi, LinkedIn / Insight, Eventi & Presidi.
Il corpo articolo è HTML prodotto da TipTap, sanitizzato lato server prima del salvataggio e del rendering.
### Autenticazione
- Password con hash bcrypt; utenti creati via script CLI (`npm run create-user`).
- Sessione: token random in cookie httpOnly, SameSite=Lax, scadenza 7 giorni, verificata da middleware su tutte le route `/admin` e `/api/admin`.
- Rate-limit sul login (protezione brute force).
### Email
`POST /api/contact` valida i campi, applica honeypot e rate-limit, invia via nodemailer con SMTP configurato in `.env` (host, porta, credenziali, destinatario — default `info@insanitylab.it`). Risposta JSON per feedback inline nel form.
### Dati statici
Agenda settimanale, prezzi, team, programmi, loghi partner: file TypeScript/JSON in `src/data/`, modificati dallo sviluppatore e versionati in git. Nessuna gestione da admin per questi contenuti al lancio.
## Deploy
- **Fase attuale (locale)**: `npm run dev`; build verificata con `npm run build && npm run preview`.
- **Versionamento**: repository git con remote su GitLab (namespace da confermare in fase di implementazione).
- **Fase 2 (VPS)**: Dockerfile multi-stage (build → `node:22-slim`), `compose.yaml` con volumi per `data/` e `public/uploads/`, porta interna 4321; aggancio a Komodo e reverse proxy per `insanitylab.tielogic.xyz` quando si porterà in produzione. Fuori scope di questa spec.
## Gestione errori
- Form contatti: validazione client + server, messaggi di errore inline, fallback se SMTP non risponde (errore esplicito all'utente, log lato server).
- Admin: redirect a `/admin/login` se sessione assente/scaduta; conferma prima dell'eliminazione articoli.
- 404 personalizzata coerente col design.
- Upload: limite dimensione (5 MB) e whitelist tipi immagine.
## Test e verifica
- vitest su: helper slug, validazione form, auth (hash/sessioni), query posts.
- Smoke test: build completa senza errori; fetch delle route principali con risposta 200 e contenuti attesi.
- Verifica visiva locale contro i PDF Figma pagina per pagina.
## Fuori scope (fasi future)
- Pagamenti online piani Performance Class (Stripe o simile).
- Gestione da admin di agenda, prezzi, team.
- Porting su VPS, DNS, TLS (fase 2 dedicata).
- Ricerca nel blog e ricerca globale (icona lente del template: omessa al lancio).
- E-commerce/carrello (icona carrello nell'header Figma: omessa al lancio, era ereditata dal template).
@@ -1,61 +0,0 @@
# Animazioni stile template Prowess — Design
Data: 2026-07-02 · Stato: approvato dall'operatore
## Obiettivo
Il sito InsanityLab, oggi quasi del tutto statico, deve riprendere il linguaggio di animazione del template WordPress "Prowess" (Qode Interactive) da cui deriva il design Figma. L'operatore ha scelto il pacchetto completo: reveal allo scroll, parallax degli sfondi, animazione d'ingresso della hero, effetti hover su card e team, e citazione con testo a scorrimento continuo.
## Evidenze raccolte dal template
Dalla demo ufficiale `prowess.qodeinteractive.com` (cassetta.me è in manutenzione):
- gli elementi appaiono allo scroll con un offset di attivazione di −100 px dal viewport (`qodefElementAppearAmount: -100`);
- due sezioni usano parallax di sfondo (`data-parallax-bg-speed="1"`);
- la hero è uno Slider Revolution con testi che entrano in sequenza;
- card e foto hanno hover con zoom immagine e transizioni morbide.
## Decisioni
1. **Implementazione vanilla**: CSS + IntersectionObserver, nessuna libreria. Il sito non ha dipendenze frontend e resta così; costo totale ~2 KB.
2. **Citazione**: si mantiene il banner su foto scura del Figma, ma il testo della citazione scorre in orizzontale a loop infinito come il marquee del template (scelta esplicita dell'operatore). La firma "Insanitylab" resta statica.
3. **Accessibilità**: con `prefers-reduced-motion: reduce` ogni animazione è disattivata (reveal, marquee, parallax, hero). Senza JavaScript il sito resta interamente visibile: lo stato nascosto si applica solo sotto `html.js`.
## Architettura
- **`src/styles/global.css`** — utilities: `[data-reveal]` (stato iniziale `opacity: 0` + `translateY(40px)`, transizione `.9s ease`; varianti `left`, `right`, `fade`; ritardo per stagger via variabile `--reveal-delay` impostata inline), keyframes marquee, blocco `prefers-reduced-motion`.
- **`src/layouts/Base.astro`** — uno script inline in `<head>` aggiunge la classe `js` a `<html>` prima del paint (evita flash); uno script bundled attiva l'IntersectionObserver (`rootMargin: 0 0 -100px`, `unobserve` dopo il primo ingresso) e il parallax (`transform: translateY` calcolato su scroll con `requestAnimationFrame`, velocità ~0.3, attivo solo da 992 px in su e senza reduced-motion).
- **Componenti** — ricevono attributi `data-reveal` e stagger inline; nessuna modifica strutturale se non il wrapper overflow per gli zoom hover e la ristrutturazione del banner citazione (sfondo su div dedicato trasformabile, testo in marquee con copia `aria-hidden`).
## Dettaglio per componente
| Componente | Effetto |
|---|---|
| HeroSlider | Ingresso al load: righe del titolo slide-up in sequenza (stagger 150 ms), bottone dopo, immagine da destra con fade |
| InfoColumns | Reveal fade con stagger 0/150/300 ms |
| FeatureBlocks | Immagine entra dal proprio lato, testo dal lato opposto |
| QuoteBanner | Sfondo parallax + testo citazione in marquee loop (pausa su hover), firma statica |
| TrainingCards | Reveal con stagger; hover zoom foto (wrapper overflow hidden) |
| ProgramsGrid | Heading reveal; item con stagger; hover: traslazione -4 px e colore |
| Agenda | Reveal heading e tabella |
| TeamSection | Head reveal; card con stagger; hover zoom foto |
| MethodSteps | Heading reveal; step con stagger |
| PartnersStrip | Reveal fade dell'intera striscia |
| PageHero (pagine interne) | Reveal del titolo all'ingresso pagina |
| Header | Transizione morbida dell'ombra già presente |
| Bottoni `.btn` | Freccia trasla a destra su hover |
## Errori e casi limite
- JS assente: nessun elemento nascosto (gate su `html.js`).
- Reduced motion: contenuto immediatamente visibile, marquee fermo su testo statico multilinea.
- Il marquee usa due copie identiche del segmento (`translateX(-50%)`) per loop senza scatti; la seconda copia è `aria-hidden`.
- Solo `opacity` e `transform`: nessun layout shift, nessun impatto CLS.
## Verifica
Build senza errori, vitest esistenti verdi, controllo visivo locale su homepage e pagine interne, poi deploy su VPS e smoke test produzione.
## Fuori scope
Slider hero multi-slide automatico (oggi esiste una sola slide), animazioni admin, contatori animati e BMI calculator (assenti dal design Figma).
@@ -1,146 +0,0 @@
# Sistema di contenuti taggati e pannello editor
Data: 2026-07-05
Stato: approvato dall'operatore (design), spec in revisione
## Obiettivo
Ogni testo e ogni immagine delle pagine vetrina del sito riceve un tag univoco e stabile. I contenuti vengono estratti dal codice e salvati in SQLite, così da poter essere modificati senza intervenire sui sorgenti. Un pannello di amministrazione (`/admin/content`) permette a un utente con ruolo dedicato di modificare testi, stili (tramite preset controllati) e immagini; una modalità "annotate" sovrappone al sito i badge con i nomi dei tag, rendendo immediato individuare l'elemento da modificare.
Il sistema serve due scopi complementari: nel breve periodo dà all'operatore un vocabolario preciso per dettare modifiche ("cambia `home.hero.title` in…"); nel medio periodo consente al cliente di aggiornare i contenuti in autonomia, senza rischio di rompere il layout.
## Decisioni prese in fase di design
| Decisione | Scelta |
| --- | --- |
| Scopo | Tagging + pannello editor nello stesso progetto |
| Ambito modifiche | Testi, immagini, stili tramite preset chiusi (no CSS libero) |
| Utenti | Ruolo `admin` (tutto) e ruolo `editor` (solo contenuti) |
| Pubblicazione | Pagine vetrina passano a SSR con cache in memoria invalidata al salvataggio |
| UX | Ibrido: pannello `/admin/content` + overlay annotate sul sito con link ai form |
## Architettura
### 1. Schema dei tag
Formato gerarchico `pagina.sezione.elemento`, tutto minuscolo, separatore punto, senza spazi:
- `home.hero.title`, `home.hero.subtitle`, `home.hero.cta`
- `about.team.donata.bio`, `about.intro.body`
- `programs.pilates-flow.price`, `training.fit-remote.description`
- `global.footer.tagline`, `global.header.cta` (elementi condivisi tra pagine)
I tag sono stabili: una volta assegnati non cambiano nome, perché sono chiave primaria nel DB e riferimento nelle conversazioni. I dati oggi strutturati in `src/data/*.ts` (team, pricing, agenda, method, programs, trainings, partners, site) vengono scomposti in tag granulari con lo stesso schema.
### 2. Storage (SQLite, DB esistente)
Nuova tabella nella `SCHEMA` di `src/lib/db.ts`:
```sql
CREATE TABLE IF NOT EXISTS content_blocks (
tag TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('text','html','image')),
value TEXT NOT NULL DEFAULT '',
styles TEXT NOT NULL DEFAULT '{}', -- JSON, solo chiavi preset
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_by TEXT
);
```
- `type = 'text'`: testo semplice, escapato in render.
- `type = 'html'`: frammenti con markup minimo (grassetti, a-capo) sanificati con la pipeline già usata dal blog (`src/lib/sanitize.ts`). Usato solo dove il testo attuale contiene markup.
- `type = 'image'`: `value` contiene il percorso dell'override caricato (es. `content/home-hero-1.jpg`, relativo a `uploads/`) oppure stringa vuota se vale l'asset originale.
### 3. Seed e fonte di verità
`src/data/content-seed.ts` contiene l'inventario completo dei tag con i valori estratti dai testi attuali: `{ tag, type, value, styleOptions }`. Il campo `styleOptions` dichiara quali preset ha senso esporre per quel tag (vedi §6); non finisce nel DB, guida solo il pannello.
All'avvio (in `createDb`) un passo di sync inserisce nel DB i tag presenti nel seed ma assenti nella tabella (`INSERT OR IGNORE`). Regole:
- In produzione il DB è la fonte di verità: le modifiche del cliente non vengono mai sovrascritte dal seed.
- I tag nuovi arrivano con il codice (aggiunta al seed) e compaiono nel DB al deploy successivo.
- I tag rimossi dal seed restano orfani nel DB e vengono ignorati; nessuna cancellazione automatica.
- Il seed nel repo funge anche da backup versionato dei testi originali.
### 4. Rendering
- Le pagine vetrina (`index`, `about`, `contact`, `training`, `programs/index`, `programs/[slug]`, `404`) passano da `prerender = true` a `prerender = false` (SSR, già attivo per blog e admin con adapter node standalone).
- Nuovo modulo `src/lib/content.ts`:
- `getContent(tag)` / `getAllContent()`: lettura con cache in memoria (`Map` popolata al primo accesso con una sola query).
- `invalidateContentCache()`: svuota la cache; chiamata dall'API di salvataggio. Modifica visibile alla richiesta successiva.
- Fallback a cascata: valore nel DB → valore nel seed → stringa vuota con log di warning. Il sito non si rompe mai per un tag mancante.
- Nuovi componenti Astro:
- `<T tag="home.hero.title" as="h1" class="…" />`: renderizza il contenuto nel tag HTML indicato (`as`, default `span`), applica le classi preset derivate da `styles`, emette `data-tag` per l'overlay annotate. Per `type='text'` il valore è escapato; per `type='html'` è già sanificato al salvataggio.
- `<TImg tag="home.hero.image" … />`: se esiste override in DB serve il file da `/uploads/content/…` (route `src/pages/uploads/[...path].ts` già esistente), altrimenti l'asset originale ottimizzato dalla pipeline Astro. Le prop di layout (dimensioni, alt di default, loading) restano nel codice.
- La sitemap non cambia (le route restano le stesse); verificare che l'integrazione sitemap continui a emetterle con `output: 'server'` e prerender disattivato, altrimenti passare a una sitemap statica generata a mano.
### 5. Ruoli utente
- Migrazione additiva: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'` (eseguita in `createDb` se la colonna manca; gli utenti esistenti restano admin).
- `getSessionUser` ritorna anche `role`; `context.locals.user` lo espone.
- Middleware: le route `/admin/content` e `/api/admin/content*` sono accessibili ad `admin` ed `editor`; tutte le altre route `/admin*` e `/api/admin*` restano riservate ad `admin` (l'editor che le richiede riceve redirect a `/admin/content` o 403 sulle API).
- Creazione utente editor: script CLI in `scripts/` (pattern già usato per l'admin), credenziali consegnate al cliente fuori banda.
### 6. Stili preset
Il DB memorizza solo chiavi simboliche; la mappa chiave → classe CSS vive nel codice (`src/lib/style-presets.ts` + classi in `global.css`). Nessun CSS arbitrario può entrare dal pannello.
| Gruppo | Chiavi | Note |
| --- | --- | --- |
| `size` | `s` / `m` / `l` | scala relativa alla dimensione base dell'elemento |
| `color` | palette del sito (brand-dark, tan, taupe, bianco, …) | lista chiusa derivata dalle variabili CSS esistenti |
| `weight` | `normal` / `bold` | |
| `style` | `normal` / `italic` | |
| `align` | `left` / `center` / `right` | solo su elementi a blocco |
Ogni tag dichiara nel seed quali gruppi espone (`styleOptions`): un titolo hero espone size e color, una voce di prezzo magari solo color. Valori non in whitelist vengono rifiutati dall'API al salvataggio.
### 7. Pannello `/admin/content`
Pagina SSR protetta, stessa shell di `src/layouts/Admin.astro`:
- Tag raggruppati per pagina (Home, About, Programs, Training, Contact, Globali), gruppi collassabili, campo di ricerca per nome tag o contenuto.
- Per ogni tag: form con campo testo (input o textarea in base alla lunghezza), controlli preset solo per i gruppi dichiarati, upload immagine per i tag `image` (validazione mime/dimensione riusando la logica dell'upload blog) con anteprima e pulsante "ripristina originale".
- Salvataggio per singolo tag (pulsante per riga) via `fetch` alle API; feedback inline di esito.
- Ancore `id` per tag: `/admin/content#home.hero.title` scrolla ed evidenzia il form corrispondente.
- Pulsante "Vedi sul sito con tag" che apre la pagina pubblica in modalità annotate.
API:
- `GET /api/admin/content` — elenco completo (per il pannello).
- `PUT /api/admin/content/[tag]` — aggiorna `value` e/o `styles`; valida tipo, preset in whitelist, sanifica se `type='html'`; aggiorna `updated_at`/`updated_by`; invalida la cache.
- `POST /api/admin/content/[tag]/image` — upload override immagine in `uploads/content/`; `DELETE` sullo stesso path rimuove l'override e ripristina l'asset originale.
### 8. Overlay annotate
- Attivazione: query string `?annotate=1` su qualunque pagina vetrina, solo con sessione valida (admin o editor). Senza sessione il parametro è ignorato: nessuna traccia dei tag per i visitatori.
- `Base.astro` in modalità annotate inietta un piccolo CSS+JS vanilla (coerente con lo stile del progetto: niente librerie): ogni elemento con `data-tag` riceve un bordo tratteggiato e un badge con il nome del tag; il click sul badge apre `/admin/content#<tag>` in nuova scheda.
- Le animazioni reveal restano attive; il badge è posizionato in modo da non alterare il layout (position absolute su wrapper relativo o outline, da verificare in implementazione).
### 9. Gestione errori
- Tag mancante nel DB e nel seed: stringa vuota + `console.warn` lato server. La pagina renderizza comunque.
- `styles` JSON corrotto nel DB: si ignora e si usa `{}`.
- Upload immagine: stessi limiti e messaggi del blog; file orfani in `uploads/content/` tollerati (nessuna garbage collection automatica).
- API: 401 senza sessione, 403 per ruolo insufficiente, 400 con messaggio esplicito per tag inesistente, tipo errato o preset fuori whitelist.
### 10. Test
- Unit (`vitest`, pattern esistente in `tests/`): content lib (fallback a cascata, invalidazione cache, sync del seed), validazione preset, migrazione colonna `role`.
- API: autorizzazione per ruolo (editor può contenuti, non può post; anonimo 401), salvataggio e rilettura, rifiuto preset non in whitelist.
- Smoke: le pagine vetrina renderizzano in SSR con contenuti dal DB; overlay annotate presente con sessione e assente senza.
## Fasi di lavoro previste (indicative per il piano)
1. Infrastruttura: tabella, migrazione ruoli, content lib con cache, componenti `<T>`/`<TImg>`, preset.
2. Estrazione e tagging: inventario completo testi/immagini pagina per pagina, creazione seed, sostituzione nei template e nei componenti (lavoro meccanico ma esteso; è la fase più lunga).
3. Pannello + API + overlay annotate + ruolo editor.
4. Test, verifica end-to-end locale, deploy e verifica in produzione.
## Fuori ambito
- CSS libero per elemento (escluso per scelta).
- Versionamento/storico delle modifiche ai contenuti (eventuale evoluzione futura).
- Modifica della struttura delle pagine (aggiunta/rimozione sezioni) dal pannello.
- Gestione multilingua.
- Blog: resta gestito dal suo admin TipTap esistente, fuori dal sistema tag.
+1180 -6
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -9,20 +9,28 @@
"preview": "astro preview", "preview": "astro preview",
"start": "node --env-file=.env ./dist/server/entry.mjs", "start": "node --env-file=.env ./dist/server/entry.mjs",
"test": "vitest run", "test": "vitest run",
"create-user": "node scripts/create-user.mjs" "create-user": "node scripts/create-user.mjs",
"seed-blog": "node scripts/seed-blog-biohacking.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^11.0.1", "@astrojs/node": "^11.0.1",
"@astrojs/react": "^6.0.2",
"@fontsource/montserrat": "^5.2.8", "@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1", "@tiptap/core": "^3.27.1",
"@tiptap/extension-image": "^3.27.1", "@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1", "@tiptap/extension-link": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1", "@tiptap/starter-kit": "^3.27.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"astro": "^7.0.5", "astro": "^7.0.5",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdfkit": "^0.19.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
"sanitize-html": "^2.17.5" "sanitize-html": "^2.17.5"
}, },
"devDependencies": { "devDependencies": {
@@ -30,6 +38,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.
+4 -2
View File
@@ -8,8 +8,10 @@ if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]'); console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1); process.exit(1);
} }
if (!['admin', 'editor'].includes(role)) { // Lista duplicata di proposito: questo file è JavaScript e non può importare da
console.error('Ruolo non valido. Ammessi: admin, editor'); // src/lib/auth.ts. La fonte vera è ROLES in src/lib/auth.ts - se cambia là, va allineata qui.
if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer');
process.exit(1); process.exit(1);
} }
const path = process.env.DB_PATH ?? 'data/insanitylab.db'; const path = process.env.DB_PATH ?? 'data/insanitylab.db';
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Genera la locandina dell'In-Sanity Hour in PDF vettoriale, con il QR code vero.
Perché non si parte da un sorgente grafico: l'unico file disponibile è un JPEG da
612x751 px, che stampato su A4 darebbe 70 dpi. La locandina però è fatta di tre soli
colori piatti, quindi si può ricalcare: ogni forma — testi, calligrafia, bicchieri —
diventa un tracciato vettoriale che si stampa nitido a qualsiasi formato, senza dover
reinstallare i font originali (che non abbiamo) né riprodurli a occhio.
Del JPEG resta solo la geometria. Il QR di prova viene coperto e ridisegnato con
l'URL vero della pagina evento, anch'esso vettoriale.
Uso: python3 scripts/locandina-evento.py
Serve: pip install --user potracer qrcode cairosvg
"""
from pathlib import Path
import cairosvg
import numpy as np
import potrace
import qrcode
from PIL import Image
URL_EVENTO = 'https://insanitylab.it/promo-eventi/eventi'
RADICE = Path(__file__).resolve().parent.parent
SORGENTE = RADICE.parent / 'Scaricati' / 'Locandina.jpeg'
USCITA_PDF = RADICE / 'public' / 'locandina-insanity-hour.pdf'
USCITA_PNG = RADICE / 'public' / 'img' / 'locandina-insanity-hour.png'
USCITA_TITOLO = RADICE / 'public' / 'img' / 'insanity-hour-titolo.png'
# Riquadro stampato dentro il JPEG, senza il bordo bianco dello screenshot.
RIQUADRO = (22, 13, 597, 732)
# Blocco "SAVE THE DATE / IN-SANITY / Hour" usato come immagine nella pagina web.
TITOLO_WEB = (23, 24, 596, 275)
# Quadrato del QR di prova da sostituire, in coordinate del JPEG originale.
QR_PROVA = (387, 632, 475, 720)
# Bordo superiore della fascia beige: la toppa sul QR non deve sconfinare nel marrone.
FASCIA_Y = 627
FONDO, BEIGE, CREMA = '#4F3126', '#D0B49E', '#F6F1E9'
CENTRI = [(79, 49, 38), (208, 180, 158), (246, 241, 233)]
UPSCALE = 4 # si traccia sull'immagine ingrandita: bordi piu' morbidi, curve piu' fedeli
MODULI_QUIET = 4 # margine muto richiesto dallo standard QR, in moduli
def classifica(immagine: Image.Image) -> np.ndarray:
"""Assegna a ogni pixel il piu' vicino dei tre colori piatti della locandina."""
grande = immagine.resize((immagine.width * UPSCALE, immagine.height * UPSCALE), Image.LANCZOS)
px = np.asarray(grande).astype(np.int16)
distanze = np.stack([((px - np.array(c)) ** 2).sum(axis=2) for c in CENTRI])
return np.argmin(distanze, axis=0)
def traccia(maschera: np.ndarray) -> str:
"""Contorni della maschera come path SVG. potracer prende per foreground i pixel
falsi, quindi la maschera va negata."""
percorso = potrace.Bitmap(~maschera).trace(turdsize=6, alphamax=1.0, opticurve=True, opttolerance=0.2)
d = []
for curva in percorso.curves:
avvio = curva.start_point
d.append(f'M{avvio.x / UPSCALE:.2f},{avvio.y / UPSCALE:.2f}')
for seg in curva.segments:
fine = seg.end_point
if seg.is_corner:
d.append(f'L{seg.c.x / UPSCALE:.2f},{seg.c.y / UPSCALE:.2f}L{fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}')
else:
d.append(
f'C{seg.c1.x / UPSCALE:.2f},{seg.c1.y / UPSCALE:.2f}'
f' {seg.c2.x / UPSCALE:.2f},{seg.c2.y / UPSCALE:.2f}'
f' {fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}'
)
d.append('Z')
return ''.join(d)
def qr_svg(x0: float, y0: float, x1: float, y1: float) -> str:
"""QR dell'URL evento, disegnato a rettangoli dentro il riquadro dato.
Il QR di prova sfiorava il bordo marrone: qui il modulo viene calcolato includendo
il margine muto, cosi' il codice resta leggibile anche stampato in piccolo.
"""
codice = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=1, border=0)
codice.add_data(URL_EVENTO)
codice.make(fit=True)
matrice = codice.get_matrix()
n = len(matrice)
lato = min(x1 - x0, y1 - y0)
modulo = lato / (n + 2 * MODULI_QUIET)
off_x = x0 + (x1 - x0 - lato) / 2 + MODULI_QUIET * modulo
off_y = y0 + (y1 - y0 - lato) / 2 + MODULI_QUIET * modulo
quadrati = [
f'<rect x="{off_x + c * modulo:.3f}" y="{off_y + r * modulo:.3f}" '
f'width="{modulo:.3f}" height="{modulo:.3f}"/>'
for r, riga in enumerate(matrice)
for c, pieno in enumerate(riga)
if pieno
]
return f'<g fill="{FONDO}" shape-rendering="crispEdges">{"".join(quadrati)}</g>', n
def main() -> None:
originale = Image.open(SORGENTE).convert('RGB')
locandina = originale.crop(RIQUADRO)
larghezza, altezza = locandina.size
etichette = classifica(locandina)
dx, dy = RIQUADRO[0], RIQUADRO[1]
qx0, qy0, qx1, qy1 = QR_PROVA[0] - dx, QR_PROVA[1] - dy, QR_PROVA[2] - dx, QR_PROVA[3] - dy
qr, moduli = qr_svg(qx0, qy0, qx1, qy1)
toppa_y = max(qy0 - 6, FASCIA_Y - dy + 1)
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {larghezza} {altezza}" width="{larghezza}" height="{altezza}">
<rect width="{larghezza}" height="{altezza}" fill="{FONDO}"/>
<path fill="{BEIGE}" d="{traccia(etichette == 1)}"/>
<path fill="{CREMA}" d="{traccia(etichette == 2)}"/>
<rect x="{qx0 - 6}" y="{toppa_y}" width="{qx1 - qx0 + 12}" height="{qy1 + 6 - toppa_y}" fill="{BEIGE}"/>
{qr}
</svg>'''
USCITA_PDF.parent.mkdir(parents=True, exist_ok=True)
# Proporzioni native (575x719). cairosvg legge i px a 96 dpi: per una base di 210 mm
# (la larghezza di un A4) la pagina va chiesta in px, non in punti.
px_per_mm = 96 / 25.4
scala = 210 * px_per_mm / larghezza
cairosvg.svg2pdf(bytestring=svg.encode(), write_to=str(USCITA_PDF),
output_width=larghezza * scala, output_height=altezza * scala)
cairosvg.svg2png(bytestring=svg.encode(), write_to=str(USCITA_PNG),
output_width=larghezza * 3, output_height=altezza * 3)
originale.crop(TITOLO_WEB).save(USCITA_TITOLO)
print(f'locandina {larghezza}x{altezza} px -> {USCITA_PDF.relative_to(RADICE)} '
f'({210:.0f}x{210 * altezza / larghezza:.0f} mm)')
print(f'anteprima {larghezza * 3}x{altezza * 3} -> {USCITA_PNG.relative_to(RADICE)}')
print(f'QR {moduli}x{moduli} moduli -> {URL_EVENTO}')
if __name__ == '__main__':
main()
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env node
/**
* seed-blog-biohacking.mjs — carica nel blog venti articoli divulgativi tratti dal corso
* base del Biohacking Campus: dieci tecnici a firma Nicola Antonelli e dieci educativi a
* firma Donata Dal Pastro.
*
* PERCHÉ UNO SCRIPT
* Gli articoli vivono nel database, che in produzione è un volume del VPS e non viene
* toccato dal deploy: creandoli a mano in locale resterebbero in locale. Questo script è
* versionato, quindi si esegue anche là.
*
* USO
* npm run seed-blog (usa data/insanitylab.db, o DB_PATH se impostata)
*
* IDEMPOTENTE
* Salta gli articoli il cui slug è già presente e non tocca quelli modificati a mano.
* Gli autori vengono creati solo se mancano, con una password casuale: per usare quegli
* account si passa dal pannello Utenti e si imposta una password.
*/
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
const DB_PATH = process.env.DB_PATH ?? 'data/insanitylab.db';
const TECNICI = 'Articoli tecnici & scientifici';
const EDUCATIVI = 'Contenuti educativi';
const NICOLA = 'Nicola Antonelli';
const DONATA = 'Donata Dal Pastro';
const p = (...paragrafi) => paragrafi.join('\n');
const articoli = [
// ---------------------------------------------------------------- tecnici
{
autore: NICOLA, categoria: TECNICI,
slug: 'sonno-cicli-non-ore',
title: 'Il sonno si misura in cicli, non in ore',
excerpt: 'Sette ore spezzate valgono meno di sei ore intere. Come funziona l\'architettura del sonno e perché conviene ragionare in blocchi da novanta minuti.',
body: p(
'<p>Quando parliamo di sonno, quasi tutti pensano a un numero: sette ore, otto ore. È una misura comoda ma imprecisa, perché il sonno non è un blocco uniforme. È una sequenza di cicli, ciascuno della durata media di novanta minuti, in cui si alternano fasi leggere, sonno profondo e fase REM.</p>',
'<p>Ogni fase ha un compito diverso. Nel sonno profondo il corpo ripara i tessuti e libera l\'ormone della crescita; nella fase REM il cervello riordina memoria ed emozioni. Interrompere un ciclo a metà significa svegliarsi nel mezzo di un lavoro non finito: è la ragione per cui a volte ci si alza più stanchi dopo otto ore che dopo sei e mezza.</p>',
'<h2>Quattro o cinque cicli, non un numero fisso</h2>',
'<p>Per un adulto l\'obiettivo ragionevole sono quattro o cinque cicli completi, cioè fra le sei e le sette ore e mezza. Contare i cicli invece delle ore cambia anche il modo di scegliere l\'orario della sveglia: meglio anticiparla di venti minuti per chiudere un ciclo, che rubarne dieci nel mezzo del successivo.</p>',
'<h2>I micro-risvegli non sono un problema</h2>',
'<p>Durante la notte ci si sveglia molte più volte di quanto si ricordi: un centinaio di micro-risvegli è considerato normale e non compromette la qualità del riposo. Sono la loro frequenza eccessiva e la difficoltà a riaddormentarsi a segnalare un sonno realmente disturbato, non il singolo risveglio notturno che tutti sperimentiamo.</p>',
'<h2>La notte si prepara di giorno</h2>',
'<p>Il punto meno intuitivo è che la qualità del sonno si decide molto prima di andare a letto. Il rilascio serale di melatonina segue un conto alla rovescia che parte dal momento in cui ci si sveglia e si prende la prima luce del giorno; il cortisolo, il suo antagonista, dovrebbe salire al mattino e scendere la sera. Luce forte a tarda ora, cene abbondanti e allenamenti intensi a ridosso della notte spostano questo equilibrio.</p>',
'<p>Per chi si allena la conseguenza è concreta: il lavoro fatto in palestra diventa adattamento durante la notte. Se il sonno è compromesso, lo stimolo c\'è ma la risposta arriva a metà.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'hrv-variabilita-cardiaca',
title: 'HRV: che cosa dice davvero la variabilità cardiaca',
excerpt: 'Non è la frequenza cardiaca e non è un voto sulla forma fisica. È la misura di quanto il sistema nervoso sa attivarsi e, soprattutto, tornare in equilibrio.',
body: p(
'<p>Sempre più orologi e anelli mostrano un valore chiamato HRV, variabilità della frequenza cardiaca. Viene spesso letto come una pagella quotidiana, ma misura qualcosa di più sottile: non quanti battiti fa il cuore in un minuto, bensì quanto varia l\'intervallo di tempo fra un battito e il successivo.</p>',
'<p>Un cuore sano non è un metronomo. Quella variazione riflette il dialogo continuo fra le due parti del sistema nervoso autonomo: il ramo simpatico, che ci attiva davanti a uno sforzo o a una scadenza, e il parasimpatico, che ci riporta al riposo e alla digestione.</p>',
'<h2>Il ritorno all\'equilibrio conta più dell\'attivazione</h2>',
'<p>La lettura più utile dell\'HRV non riguarda la capacità di rispondere allo stress, che quasi tutti hanno, ma quella di rientrare in equilibrio quando lo stimolo finisce. Essere in forma significa saper accendere e saper spegnere. Un valore stabilmente basso racconta spesso un sistema che resta acceso anche quando non serve.</p>',
'<h2>Il numero da solo non significa nulla</h2>',
'<p>L\'HRV ha una fortissima componente individuale: confrontare il proprio valore con quello di un\'altra persona non ha senso, perché dipende dall\'età, dalla genetica e dall\'algoritmo del dispositivo. Ha senso invece confrontarlo con se stessi nel tempo, guardando il trend su settimane e mesi.</p>',
'<h2>Come si legge in pratica</h2>',
'<ul>',
'<li>Un calo isolato dopo un allenamento intenso o una serata storta è fisiologico.</li>',
'<li>Un calo che si protrae per giorni, insieme a sonno peggiore e frequenza a riposo più alta, suggerisce di alleggerire il carico.</li>',
'<li>Alcol, cena tardi, allenamento serale e febbre incidono più di quanto si immagini.</li>',
'</ul>',
'<p>Il valore aggiunto non è il numero, ma il mettere in relazione più dati: sonno, carico di allenamento, HRV e frequenza a riposo raccontano insieme una storia che nessuno dei quattro racconta da solo.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'quante-proteine-servono',
title: 'Quante proteine servono davvero',
excerpt: 'Prima la quantità, poi la qualità, solo alla fine distribuzione e timing. La gerarchia che conta, con i numeri per chi si allena e per chi no.',
body: p(
'<p>Sulle proteine circolano più opinioni che dati. Un modo ordinato di ragionare è pensarle come una piramide a quattro livelli: alla base la quantità totale della giornata, sopra la qualità delle fonti, poi la distribuzione nei pasti e infine il timing rispetto all\'allenamento. Sistemare la base vale la gran parte del risultato; occuparsi del vertice ignorando le fondamenta è come montare le finestre prima dei muri.</p>',
'<h2>I numeri di riferimento</h2>',
'<p>Per una persona sedentaria il riferimento minimo è intorno a 0,8 grammi per chilo di peso corporeo al giorno. Per chi si allena con regolarità e vuole mantenere o costruire massa muscolare si sale a circa 1,5-1,6 grammi per chilo, con margini fino a 1,8-2 nelle fasi di carico o di dimagrimento.</p>',
'<p>Tradotto: una persona di 70 chili che va in palestra tre volte a settimana si colloca intorno ai 105-115 grammi al giorno, non ai 56 del riferimento minimo per chi non si muove.</p>',
'<h2>Il muscolo è un organo, non un ornamento</h2>',
'<p>La massa muscolare regola il metabolismo, protegge le articolazioni, partecipa all\'equilibrio ormonale e si correla alla capacità di restare autonomi con l\'età. È però un tessuto costoso da mantenere: in assenza di stimolo e di materiale l\'organismo lo smonta. La perdita progressiva di muscolo, la sarcopenia, comincia prima di quanto si creda, già a partire dai venticinque anni, e accelera senza allenamento contro resistenza.</p>',
'<h2>Conta il cronico, non il singolo giorno</h2>',
'<p>Una giornata sotto la quota non produce alcun danno. È il deficit ripetuto per mesi a portare al catabolismo. Vale anche il contrario: nessun singolo pasto proteico compensa settimane di apporto insufficiente.</p>',
'<p>Sulla distribuzione, l\'indicazione pratica è ripartire le proteine su tre o quattro pasti invece di concentrarle a cena, perché la sintesi proteica risponde meglio a stimoli ripetuti. Ma è una rifinitura: ha senso occuparsene quando la quantità totale è già a posto.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'creatina-cosa-dice-la-ricerca',
title: 'Creatina: che cosa dice la ricerca',
excerpt: 'È l\'integratore più studiato in assoluto e uno dei pochi con prove solide. A cosa serve, quanta prenderne e perché non è una sostanza dopante.',
body: p(
'<p>La creatina è probabilmente l\'integratore su cui esiste più letteratura scientifica, con decenni di studi alle spalle. Non è un ormone né una sostanza dopante: è un composto che l\'organismo produce e che assumiamo anche con la carne e il pesce, immagazzinato soprattutto nel muscolo.</p>',
'<h2>Come funziona</h2>',
'<p>Serve a rigenerare rapidamente l\'ATP, la molecola che fornisce energia nelle contrazioni brevi e intense. Avere i depositi pieni significa disporre di qualche ripetizione in più negli sforzi massimali, e quindi accumulare nel tempo un volume di lavoro maggiore. Il beneficio non è immediato: è il risultato di mesi di allenamento leggermente più produttivo.</p>',
'<h2>Quanta e quando</h2>',
'<p>Il dosaggio più studiato è di circa 3-5 grammi al giorno di creatina monoidrato, tutti i giorni, compresi quelli di riposo. Il momento della giornata è ininfluente: ciò che conta è la saturazione dei depositi, che si raggiunge in qualche settimana di assunzione costante. Le formulazioni più costose non hanno mostrato vantaggi rispetto al monoidrato.</p>',
'<h2>Effetti collaterali e falsi miti</h2>',
'<ul>',
'<li>Il piccolo aumento di peso iniziale è acqua trattenuta dentro il muscolo, non grasso.</li>',
'<li>Nelle persone con funzione renale normale non sono emersi danni ai reni alle dosi abituali.</li>',
'<li>Non provoca crampi né disidratazione, al contrario di quanto si diceva anni fa.</li>',
'</ul>',
'<p>Un aspetto meno noto riguarda il cervello, che utilizza anch\'esso creatina: alcune ricerche indicano effetti sulla fatica mentale e sulle prestazioni cognitive, in particolare in condizioni di privazione di sonno. Le prove qui sono più recenti e meno solide che sul versante muscolare.</p>',
'<p>Come sempre, un integratore lavora sul margine. Con un allenamento discontinuo e proteine insufficienti, la creatina non ha molto su cui agire.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'freddo-ormesi',
title: 'Il freddo e l\'ormesi: perché un piccolo stress fa bene',
excerpt: 'Vasi sanguigni, grasso bruno, noradrenalina. Che cosa accade nell\'organismo durante l\'esposizione al freddo e dove sta il confine con l\'esagerazione.',
body: p(
'<p>Alla base dell\'esposizione al freddo c\'è un principio generale della biologia: l\'ormesi. Uno stimolo che a dosi elevate sarebbe dannoso, somministrato a dosi contenute produce l\'effetto opposto e rende l\'organismo più resistente. Vale per l\'esercizio fisico, per il digiuno e appunto per il freddo.</p>',
'<h2>Che cosa succede nel corpo</h2>',
'<p>Il primo effetto riguarda i vasi sanguigni. I cicli di vasocostrizione e vasodilatazione allenano la muscolatura liscia che li avvolge, migliorando nel tempo la microcircolazione. È un lavoro che il sistema cardiovascolare non riceve in nessun altro modo.</p>',
'<p>Il secondo riguarda il tessuto adiposo bruno, ricco di mitocondri, che sotto una certa temperatura brucia energia per produrre calore invece di immagazzinarla. Il terzo è nervoso: l\'esposizione al freddo aumenta in modo marcato la noradrenalina, oltre a dopamina e serotonina, ed è la ragione della lucidità e del buonumore che molti riferiscono dopo.</p>',
'<h2>Il disagio è parte del meccanismo</h2>',
'<p>Il beneficio non nasce dal piacere ma dalla condizione di stress controllato. Una doccia tiepida non produce gli stessi adattamenti di un\'immersione realmente fredda. Allo stesso tempo, dose e durata contano: qui più non è meglio.</p>',
'<h2>Le cautele che vale la pena conoscere</h2>',
'<ul>',
'<li>L\'<em>afterdrop</em> è il calo della temperatura interna che avviene <strong>dopo</strong> l\'uscita dall\'acqua, non durante: è il momento più delicato, e va gestito riscaldandosi gradualmente.</li>',
'<li>Chi ha patologie cardiovascolari o pressorie dovrebbe parlarne prima con il proprio medico.</li>',
'<li>Il freddo subito dopo un allenamento di forza smorza parte della risposta infiammatoria che serve all\'adattamento muscolare: meglio distanziarlo di qualche ora.</li>',
'</ul>',
'<p>Vale la pena ricordare che l\'infiammazione acuta è un processo utile e necessario. L\'obiettivo non è spegnerla, ma contenere quella cronica di basso grado che accompagna sedentarietà, sonno scarso e stress prolungato.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'respirazione-anidride-carbonica',
title: 'Respirare meno per ossigenare meglio: il ruolo della CO2',
excerpt: 'Non è l\'ossigeno a mancare, ma l\'anidride carbonica a servire. Come funziona lo scambio nei tessuti e perché la respirazione nasale cambia le cose.',
body: p(
'<p>Respiriamo più di ventimila volte al giorno senza pensarci. È proprio l\'automatismo a rendere poco intuitivo il meccanismo: quasi tutti immaginano che respirare di più significhi ossigenarsi meglio. In condizioni normali il sangue arterioso è già saturo di ossigeno quasi al massimo, e il fattore limitante è un altro.</p>',
'<h2>La CO2 non è solo uno scarto</h2>',
'<p>L\'anidride carbonica regola il rilascio dell\'ossigeno dall\'emoglobina ai tessuti. Con livelli di CO2 troppo bassi, l\'ossigeno resta legato ai globuli rossi e arriva meno dove serve. Iperventilare, cioè respirare più del necessario, produce quindi l\'effetto opposto a quello desiderato.</p>',
'<p>Da qui l\'utilità di allenare la tolleranza alla CO2 con respirazioni lente e ritenzioni brevi: non si tratta di prendere più aria, ma di usarne meglio quella che entra.</p>',
'<h2>Il respiro come interruttore del sistema nervoso</h2>',
'<p>Il respiro è l\'unica funzione autonoma che possiamo controllare volontariamente, e questo lo rende una leva diretta sul sistema nervoso. Accelerare il ritmo attiva il ramo simpatico, quello dell\'allerta; rallentare e soprattutto allungare l\'espirazione attiva il parasimpatico, quello del recupero. È il motivo per cui un\'espirazione più lunga dell\'inspirazione abbassa la frequenza cardiaca in pochi minuti.</p>',
'<h2>Perché dal naso</h2>',
'<p>La respirazione nasale filtra, riscalda e umidifica l\'aria, e produce ossido nitrico, che favorisce la vasodilatazione. Respirare dalla bocca a riposo, soprattutto di notte, è associato a sonno più frammentato e secchezza delle vie aeree.</p>',
'<p>Per chi si allena, la respirazione nasale nei lavori a bassa intensità è un allenamento in sé: all\'inizio costringe a rallentare, poi migliora l\'efficienza. Nelle intensità elevate la bocca resta necessaria, ed è del tutto normale.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'luce-orologio-biologico',
title: 'La luce che regola l\'orologio biologico',
excerpt: 'Un segnale nella luce del mattino fa partire la giornata metabolica. E gli schermi della sera raccontano al cervello che è ancora mezzogiorno.',
body: p(
'<p>Ci siamo evoluti per milioni di anni sotto il sole, e i nostri processi biologici sono sincronizzati sul ciclo luce-buio. La luce non è un contorno ambientale: è il segnale principale che coordina l\'orologio interno.</p>',
'<h2>Il segnale del mattino</h2>',
'<p>Nella luce dell\'alba è presente una componente blu che, attraverso l\'occhio, raggiunge il nucleo soprachiasmatico, l\'orologio centrale situato nell\'ipotalamo. Da lì parte a cascata la giornata metabolica: risveglio, produzione di cortisolo, attivazione della digestione e della muscolatura. È anche il momento in cui viene impostato il conto alla rovescia per la melatonina serale.</p>',
'<p>In pratica: dieci o quindici minuti all\'aperto entro la prima ora dal risveglio hanno un effetto che nessuna lampada da interno riesce a replicare, perché l\'intensità luminosa esterna è di ordini di grandezza superiore anche in una giornata nuvolosa.</p>',
'<h2>Il problema della sera</h2>',
'<p>I LED di lampade, televisori e telefoni emettono lunghezze d\'onda simili a quelle della luce diurna. Usarli a tarda ora comunica al cervello che è ancora giorno, tenendo alto lo stato di allerta e ritardando la melatonina — che, oltre a regolare il sonno profondo, è uno dei più potenti antiossidanti prodotti dall\'organismo.</p>',
'<h2>Anche la pelle riceve luce</h2>',
'<p>Non sono solo gli occhi a raccogliere il segnale: la pelle contiene fotorecettori e partecipa alla regolazione dello stato di veglia. È una delle ragioni per cui l\'esposizione all\'aperto ha effetti che una luce artificiale puntata sugli occhi non riproduce.</p>',
'<p>Un ultimo punto, spesso frainteso: addormentarsi in pochi secondi non è segno di buon sonno. Più spesso indica un debito accumulato. Un addormentamento in dieci-venti minuti è considerato fisiologico.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'recupero-attivo',
title: 'Recupero attivo: perché fermarsi non basta',
excerpt: 'Il divano è riposo, non recupero. Sistema linfatico, tessuto connettivo e la differenza fra togliere il carico e favorire la rigenerazione.',
body: p(
'<p>Il recupero viene quasi sempre inteso in negativo: non allenarsi, non fare nulla. È una lettura parziale. Il riposo passivo serve, ma la rigenerazione dei tessuti si accelera con il movimento, non con l\'immobilità.</p>',
'<h2>Il sistema linfatico non ha una pompa</h2>',
'<p>A differenza del sangue, che ha il cuore, la linfa si muove grazie alla contrazione muscolare e alla respirazione. Restare fermi rallenta il drenaggio dei cataboliti prodotti dall\'allenamento; una camminata, una pedalata leggera o una sessione di mobilità lo favoriscono. È la logica del recupero attivo.</p>',
'<h2>La fascia va tenuta fluida</h2>',
'<p>Il tessuto connettivo che avvolge muscoli e organi è ricco di acido ialuronico, una molecola sensibile alla temperatura che tende a irrigidirsi con il freddo e la sedentarietà. Movimento a bassa intensità, calore e lavoro sui tessuti aiutano a mantenerlo elastico, con effetti diretti sulla percezione di rigidità del giorno dopo.</p>',
'<h2>Carico e recupero sono due fasi della stessa cosa</h2>',
'<p>Il progresso non nasce dall\'allenamento ma dall\'adattamento all\'allenamento, che avviene dopo. Quando l\'energia spesa supera stabilmente quella rigenerata, il risultato non è più allenamento: è affaticamento cronico. Per questo il recupero andrebbe programmato nella settimana come si programma una seduta, non lasciato ai ritagli.</p>',
'<h2>Attenzione a non assistere troppo il corpo</h2>',
'<p>C\'è un limite alla logica dell\'aiuto esterno. Riempire l\'organismo di antiossidanti e integratori attorno all\'allenamento rischia di smorzare proprio le risposte che l\'allenamento dovrebbe stimolare. Il corpo ha bisogno di essere sollecitato e poi messo nelle condizioni di rispondere, non di essere protetto da ogni stimolo.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'vo2max-longevita',
title: 'VO2max: l\'indicatore che parla di longevità',
excerpt: 'Quanta energia sai erogare nel tempo. È fra i parametri più correlati alla salute futura, e si allena a qualsiasi età.',
body: p(
'<p>Il VO2max misura il massimo volume di ossigeno che l\'organismo riesce a utilizzare nell\'unità di tempo. Detto in modo più diretto: quanta energia si è capaci di erogare in modo sostenuto. È un indicatore nato nel mondo della prestazione sportiva, ma oggi viene considerato uno dei parametri più informativi sullo stato di salute complessivo.</p>',
'<h2>Perché conta anche per chi non gareggia</h2>',
'<p>Un VO2max più alto significa un margine più ampio fra ciò che la vita quotidiana richiede e ciò che l\'organismo può erogare. Quel margine è ciò che permette di salire le scale con la spesa a sessant\'anni, e di reggere meglio una malattia o un intervento.</p>',
'<h2>Si allena, e non solo correndo</h2>',
'<p>Migliora con il lavoro cardiovascolare strutturato: molto volume a bassa intensità, dove si respira ancora dal naso e si riesce a parlare, più una quota limitata di lavori intensi. La combinazione conta più della singola seduta dura, e i risultati arrivano con cicli di carico e scarico, non con settimane tutte uguali.</p>',
'<h2>La palestra non è uno sport</h2>',
'<p>È un\'osservazione utile a smontare un pregiudizio frequente. La sala pesi non è una disciplina a sé che produce solo muscoli visibili: è il lavoro complementare di cui hanno bisogno il corridore, il tennista, il nuotatore e chiunque voglia arrivare lontano in salute. Forza e capacità aerobica non sono alternative, sono due gambe dello stesso percorso.</p>',
'<p>Gli orologi sportivi stimano il VO2max con formule indirette: il valore assoluto va preso con prudenza, la sua tendenza nel tempo è invece un\'informazione utile.</p>'
),
},
{
autore: NICOLA, categoria: TECNICI,
slug: 'leggere-i-dati-dei-dispositivi',
title: 'Leggere i dati di orologi e anelli senza farsi ingannare',
excerpt: 'Un numero isolato non dice quasi nulla. Le tre regole per trasformare i dati di un dispositivo indossabile in decisioni sensate.',
body: p(
'<p>Un dispositivo indossabile produce ogni giorno decine di valori: sonno, recupero, sforzo, prontezza, frequenza a riposo, variabilità cardiaca. Il rischio è passare dal non sapere nulla al sapere troppo, senza che questo migliori una sola decisione.</p>',
'<h2>Prima regola: guardare il trend, non il giorno</h2>',
'<p>Un valore isolato è rumore. Il punteggio di recupero basso del martedì non significa nulla da solo; la stessa metrica in calo per otto giorni consecutivi è un\'informazione. Ha senso ragionare su finestre di una settimana, un mese, un trimestre.</p>',
'<h2>Seconda regola: incrociare i parametri</h2>',
'<p>Il valore aggiunto sta nel mettere in relazione i dati fra loro. Una variabilità cardiaca crollata si spiega spesso con un allenamento serale, una cena tardiva o un bicchiere di troppo. Presa da sola diventa un allarme; letta insieme al resto diventa una spiegazione.</p>',
'<h2>Terza regola: sapere che cosa misura il proprio dispositivo</h2>',
'<p>Gli strumenti non sono equivalenti. Alcuni nascono orientati allo sport e pesano molto il carico di allenamento; altri nascono come strumenti per il sonno e restituiscono un quadro più orientato allo stile di vita. Misurano grandezze simili, ma le elaborano con algoritmi diversi: confrontare il proprio numero con quello di un amico che usa un altro dispositivo non ha alcun significato.</p>',
'<h2>E il corpo?</h2>',
'<p>Il dato non sostituisce la sensazione, la contestualizza. Se il dispositivo segnala recupero pieno ma ci si sente a pezzi, l\'informazione utile è la seconda. Gli strumenti servono a rendere visibili le tendenze che la percezione quotidiana fatica a cogliere, non a scavalcarla.</p>'
),
},
// -------------------------------------------------------------- educativi
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'dormire-meglio-da-stasera',
title: 'Sette abitudini per dormire meglio da stasera',
excerpt: 'Nessun integratore, nessuna spesa. Piccoli gesti che spostano davvero la qualità del riposo, da provare uno alla volta.',
body: p(
'<p>Dormire bene è la cosa più economica che possiamo fare per la nostra salute, e spesso la più trascurata. Non servono acquisti: serve preparare la notte durante il giorno. Ecco sette abitudini, in ordine di facilità.</p>',
'<h2>1. Uscire la mattina</h2>',
'<p>Dieci minuti di luce naturale entro un\'ora dal risveglio danno il via all\'orologio interno e, di conseguenza, alla melatonina della sera. Anche con il cielo coperto.</p>',
'<h2>2. Andare a letto e alzarsi a orari simili</h2>',
'<p>La regolarità conta più della durata. Un orario stabile, weekend compresi, vale più di un\'ora in più presa a casaccio.</p>',
'<h2>3. Cenare prima e più leggeri</h2>',
'<p>Una digestione in corso alza la temperatura interna e disturba le prime ore di sonno, quelle più profonde. Meglio finire almeno due o tre ore prima di coricarsi.</p>',
'<h2>4. Abbassare le luci dopo cena</h2>',
'<p>Non serve vivere a lume di candela: basta spegnere il lampadario centrale e usare luci basse e calde. Il cervello legge la penombra come segnale di sera.</p>',
'<h2>5. Fermare il caffè presto</h2>',
'<p>La caffeina resta in circolo per molte ore: il caffè delle cinque del pomeriggio è ancora presente a mezzanotte. Per la maggior parte delle persone conviene chiudere all\'ora di pranzo.</p>',
'<h2>6. Tenere la camera fresca e buia</h2>',
'<p>La temperatura interna deve scendere perché il sonno profondo arrivi: una stanza attorno ai 18 gradi aiuta. Buio significa buio davvero, anche le lucine degli apparecchi.</p>',
'<h2>7. Costruire un piccolo rituale</h2>',
'<p>Sempre gli stessi gesti nella mezz\'ora prima di dormire — una doccia, qualche pagina, due minuti di respiro lento — insegnano al corpo che cosa sta per succedere.</p>',
'<p>Un consiglio pratico: non provarle tutte insieme. Se ne scelgono due, si tengono per dieci giorni e si valuta come ci si sente al risveglio.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'muoversi-ogni-giorno',
title: 'Muoversi ogni giorno: quanto basta davvero',
excerpt: 'Fra il divano e la maratona c\'è tutto lo spazio che serve. Come mettere insieme una settimana di movimento sostenibile, anche partendo da zero.',
body: p(
'<p>La domanda che arriva più spesso è: quanto devo allenarmi? La risposta onesta è che il primo obiettivo non è la quantità, è la continuità. Tre sedute costanti per un anno valgono infinitamente più di sei sedute per tre settimane.</p>',
'<h2>Le tre cose che non dovrebbero mancare</h2>',
'<ul>',
'<li><strong>Camminare tutti i giorni.</strong> È la base gratuita su cui si appoggia tutto il resto. Va benissimo distribuita: due tratte a piedi al posto dell\'auto valgono quanto un\'unica passeggiata.</li>',
'<li><strong>Lavorare contro resistenza due volte a settimana.</strong> Pesi, elastici, corpo libero. Serve a mantenere il muscolo, che dopo i venticinque anni si perde spontaneamente se non lo si usa.</li>',
'<li><strong>Alzare il fiato una volta a settimana.</strong> Una salita, una corsa breve, una lezione più intensa: qualcosa che faccia respirare a fondo.</li>',
'</ul>',
'<h2>"Non voglio andare in palestra, non voglio i muscoli"</h2>',
'<p>È un\'obiezione frequente e nasce da un equivoco: la sala pesi non è una disciplina sportiva, è un luogo pieno di attrezzi che serve a sostenere tutto il resto. Il corridore ci va per non infortunarsi, chi ha mal di schiena per rinforzare ciò che lo protegge, chi ha superato i sessanta per continuare a salire le scale con la spesa in mano.</p>',
'<h2>Partire da dove si è</h2>',
'<p>Chi arriva da mesi di fermo non ha bisogno di un programma da atleta, ha bisogno di un programma che riesca a rispettare. Due sedute a settimana, brevi, con esercizi semplici, sono un punto di partenza serio. Il carico si aggiunge dopo, quando l\'abitudine regge da sola.</p>',
'<p>Un ultimo pensiero: il corpo non distingue fra allenamento e vita. Portare la spesa, fare le scale, andare in bicicletta al lavoro contano davvero, e per molte persone sono la parte più grande del movimento settimanale.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'il-peso-non-e-solo-la-bilancia',
title: 'Il peso non si decide sulla bilancia',
excerpt: 'Cinque pilastri in ordine di importanza, e perché il lavoro vero avviene mentre dormiamo.',
body: p(
'<p>Quando si parla di perdere peso l\'attenzione va quasi sempre a un dettaglio: un alimento da eliminare, un integratore, una nuova dieta. Le cose che contano davvero sono poche e stanno in una gerarchia precisa.</p>',
'<h2>La piramide, dal basso verso l\'alto</h2>',
'<ol>',
'<li><strong>Il bilancio calorico.</strong> Per perdere grasso bisogna consumare più di quanto si introduce. Comunque lo si ottenga, senza questo non succede nulla.</li>',
'<li><strong>Il sonno.</strong> Dormire poco altera gli ormoni della fame e rende molto più difficile mantenere il resto.</li>',
'<li><strong>Le proteine.</strong> Saziano, proteggono il muscolo mentre si dimagrisce e riducono la perdita di massa magra.</li>',
'<li><strong>Il lavoro con i pesi.</strong> Dà all\'organismo la ragione per tenere il muscolo invece di smontarlo.</li>',
'<li><strong>Il cardio.</strong> Utile, ma all\'ultimo posto: aiuta il bilancio, non lo sostituisce.</li>',
'</ol>',
'<h2>Il weekend conta come i giorni feriali</h2>',
'<p>È l\'inciampo più comune: cinque giorni impeccabili e due giorni in cui, senza accorgersene, si recupera tutto il deficit costruito. Non serve rinunciare alla pizza; serve sapere che la settimana si giudica per intero.</p>',
'<h2>Il dimagrimento avviene di notte</h2>',
'<p>Gli stimoli si danno di giorno — mangiando in un certo modo, allenandosi — ma il lavoro metabolico si compie durante il riposo. Una parte importante del grasso smaltito lascia l\'organismo attraverso la respirazione notturna. È un altro motivo per cui chi dorme male fatica a vedere risultati anche facendo tutto il resto.</p>',
'<h2>Il corpo deve stare tranquillo</h2>',
'<p>Stress prolungato, notti corte, giornate senza pause: in queste condizioni l\'organismo tende a trattenere. Prima di aggiungere un\'altra restrizione alimentare, spesso conviene togliere qualcosa dal carico complessivo.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'proteine-a-tavola',
title: 'Proteine a tavola: come distribuirle nella giornata',
excerpt: 'Senza bilancia e senza calcoli complicati. Quantità pratiche, fonti quotidiane e l\'errore più comune: concentrare tutto a cena.',
body: p(
'<p>Le proteine sono il mattone con cui l\'organismo ripara e mantiene i tessuti, muscolo compreso. Molte persone ne assumono meno di quanto pensino, soprattutto a colazione e a pranzo.</p>',
'<h2>Un ordine di grandezza, non un calcolo</h2>',
'<p>Per chi si allena con regolarità, un riferimento pratico è circa un grammo e mezzo per chilo di peso al giorno: attorno ai cento grammi per una persona di settanta chili. Per chi ha una vita più sedentaria il fabbisogno è più basso, ma resta più alto di quanto la maggior parte delle persone arrivi a coprire.</p>',
'<h2>Che cosa significa in pratica</h2>',
'<p>Senza pesare nulla, aiuta ricordare gli ordini di grandezza: una porzione di carne o pesce da mano aperta fornisce circa 25-30 grammi di proteine, due uova circa 12, un vasetto di yogurt greco 15-20, una porzione di legumi 12-15.</p>',
'<h2>L\'errore più comune</h2>',
'<p>Colazione dolce, pranzo veloce, e tutte le proteine concentrate a cena. L\'organismo utilizza meglio stimoli ripetuti nell\'arco della giornata: distribuirle su tre o quattro pasti rende lo stesso totale più efficace, e per di più aiuta a non arrivare affamati alla sera.</p>',
'<ul>',
'<li>A colazione: yogurt, uova, ricotta, o una fonte proteica accanto ai carboidrati abituali.</li>',
'<li>A pranzo: che ci sia sempre una fonte riconoscibile, non solo un piatto di pasta.</li>',
'<li>A cena: la porzione consueta, senza doverla raddoppiare per compensare.</li>',
'</ul>',
'<h2>E le polveri?</h2>',
'<p>Sono un alimento comodo, non un prodotto magico: servono quando raggiungere la quota con il cibo è difficile per abitudini o orari. Chi già ci arriva mangiando non ne ha bisogno.</p>',
'<p>Un\'ultima cosa: quello che conta è la media delle settimane, non la giornata perfetta. Un giorno sotto la quota non rovina nulla.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'doccia-fredda-come-iniziare',
title: 'Doccia fredda: come iniziare senza esagerare',
excerpt: 'Una progressione in quattro settimane per abituarsi al freddo, con le avvertenze da tenere presenti prima di cominciare.',
body: p(
'<p>L\'esposizione al freddo è diventata popolare, spesso nella sua versione più estrema. La buona notizia è che per ottenere qualcosa non serve un bagno di ghiaccio: basta la doccia di casa, con una progressione ragionevole.</p>',
'<h2>Perché fa bene</h2>',
'<p>Il freddo allena i vasi sanguigni attraverso cicli di restringimento e dilatazione, migliorando la circolazione periferica. Aumenta inoltre i neurotrasmettitori legati ad attenzione e buonumore: è la ragione della sensazione di lucidità che resta per ore.</p>',
'<h2>Una progressione realistica</h2>',
'<ul>',
'<li><strong>Settimana 1.</strong> Doccia normale, poi trenta secondi di acqua fredda alla fine, partendo dai piedi e salendo.</li>',
'<li><strong>Settimana 2.</strong> Un minuto, respirando lentamente dal naso invece di trattenere il fiato.</li>',
'<li><strong>Settimana 3.</strong> Due minuti, includendo la nuca e le spalle.</li>',
'<li><strong>Settimana 4.</strong> Due o tre minuti, quattro o cinque volte a settimana.</li>',
'</ul>',
'<p>Il momento di disagio fa parte del meccanismo: se l\'acqua è appena tiepida non succede granché. Il respiro è lo strumento per gestirlo — quando accorcia e si fa affannoso, si rallenta l\'espirazione.</p>',
'<h2>Le avvertenze</h2>',
'<p>Chi ha problemi di cuore, di pressione o è in gravidanza dovrebbe parlarne prima con il proprio medico. Mai in acque libere da soli, e attenzione al riscaldamento dopo: la temperatura interna continua a scendere per qualche minuto anche una volta usciti, quindi si torna al caldo con gradualità, senza passare direttamente a un bagno bollente.</p>',
'<h2>Un dettaglio per chi si allena</h2>',
'<p>Subito dopo una seduta di pesi il freddo attenua parte della risposta che serve al muscolo per adattarsi. Se l\'obiettivo è la massa, meglio spostarlo al mattino o comunque a distanza di qualche ora dall\'allenamento.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'cinque-minuti-di-respiro',
title: 'Cinque minuti di respiro al giorno',
excerpt: 'Un esercizio semplice da fare ovunque per abbassare la tensione, e come usarlo prima di dormire o prima di una situazione difficile.',
body: p(
'<p>Il respiro è l\'unica funzione automatica che possiamo guidare a piacere. Questo lo rende lo strumento più immediato che abbiamo per intervenire sul nostro stato: non richiede attrezzi, nessuno se ne accorge e funziona in pochi minuti.</p>',
'<h2>La regola di base</h2>',
'<p>Espirare più a lungo di quanto si inspira porta il corpo verso il rilassamento. Accelerare il respiro fa il contrario, attiva. Tutto qui: il resto sono variazioni su questo principio.</p>',
'<h2>L\'esercizio da cui partire</h2>',
'<p>Seduti comodi, schiena appoggiata, respirando dal naso:</p>',
'<ul>',
'<li>Inspira contando fino a quattro.</li>',
'<li>Espira lentamente contando fino a sei.</li>',
'<li>Continua per cinque minuti, senza forzare.</li>',
'</ul>',
'<p>Se contare distrae, basta rendere l\'espirazione visibilmente più lunga dell\'inspirazione. La mano appoggiata sulla pancia aiuta a capire se il respiro è basso, diaframmatico, oppure resta alto nel petto.</p>',
'<h2>Quando usarlo</h2>',
'<ul>',
'<li><strong>Prima di dormire</strong>, a luci basse: prepara il passaggio al riposo.</li>',
'<li><strong>Prima di una situazione che mette pressione</strong>: una riunione, un esame, una telefonata rimandata.</li>',
'<li><strong>Dopo l\'allenamento</strong>, per accompagnare il ritorno alla calma.</li>',
'<li><strong>Nei risvegli notturni</strong>, invece di guardare l\'orologio.</li>',
'</ul>',
'<h2>Due cose da sapere</h2>',
'<p>La prima: respirare di più non significa ossigenarsi meglio. Le respirazioni profonde e affannate a vuoto spesso peggiorano la sensazione di fame d\'aria. La seconda: di giorno, a riposo, conviene respirare dal naso — filtra, riscalda e umidifica l\'aria, e la respirazione risulta più efficiente.</p>',
'<p>Cinque minuti al giorno per due settimane sono sufficienti per accorgersi della differenza, soprattutto sulla qualità del riposo.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'luce-del-mattino',
title: 'La luce del mattino è la sveglia naturale',
excerpt: 'Dieci minuti all\'aperto appena alzati cambiano il sonno della notte successiva. E la sera, qualche accorgimento con gli schermi.',
body: p(
'<p>C\'è un gesto che costa zero e migliora il sonno più di molti integratori: uscire all\'aperto poco dopo essersi alzati.</p>',
'<h2>Come funziona</h2>',
'<p>La luce del mattino contiene un segnale che raggiunge l\'orologio interno e gli comunica che la giornata è cominciata. Da quel momento parte una catena di attivazioni — energia, digestione, attenzione — e viene impostato il conto alla rovescia per la melatonina, l\'ormone che la sera concilierà il sonno. È per questo che la notte si prepara al mattino.</p>',
'<h2>Quanto e come</h2>',
'<p>Dieci o quindici minuti nella prima ora dal risveglio sono sufficienti. Non serve sole pieno: anche con il cielo coperto la luce esterna è molto più intensa di qualsiasi lampada di casa. Vale il caffè sul balcone, la prima parte del tragitto a piedi, il cane portato fuori senza fretta. Attraverso il vetro della finestra l\'effetto è molto ridotto.</p>',
'<h2>E la sera</h2>',
'<p>Le luci bianche e gli schermi raccontano al cervello che è ancora giorno. Non serve rinunciare alla televisione: bastano un paio di accorgimenti.</p>',
'<ul>',
'<li>Spegnere le luci centrali dopo cena e usare lampade basse e calde.</li>',
'<li>Attivare la modalità notturna su telefono e computer.</li>',
'<li>Tenere la camera completamente buia, comprese le spie degli apparecchi.</li>',
'</ul>',
'<h2>Per chi lavora su turni</h2>',
'<p>Chi ha orari che cambiano non può seguire il sole, ma può essere coerente: luce forte all\'inizio del proprio turno, penombra nelle ore precedenti al sonno, camera buia e fresca anche di giorno. La regolarità possibile vale più della regolarità perfetta.</p>',
'<p>Una nota che sorprende molti: addormentarsi in pochi secondi non è un buon segno. Di solito indica un debito di sonno accumulato.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'quando-fermarsi',
title: 'Come capire quando serve fermarsi',
excerpt: 'I segnali che distinguono la stanchezza normale da quella che chiede una pausa, e cosa fare nei giorni di scarico.',
body: p(
'<p>Chi si allena con costanza prima o poi incontra la stessa domanda: oggi vado o mi fermo? Rispondere solo con la forza di volontà porta lontano finché regge, poi presenta il conto.</p>',
'<h2>I segnali che vale la pena osservare</h2>',
'<ul>',
'<li>Sonno peggiorato pur andando a letto alla stessa ora.</li>',
'<li>Frequenza cardiaca a riposo più alta del solito per più giorni.</li>',
'<li>Irritabilità, motivazione in calo, voglia di saltare l\'allenamento che dura da una settimana.</li>',
'<li>Prestazioni in discesa nonostante l\'impegno non sia cambiato.</li>',
'<li>Piccoli fastidi articolari che compaiono e non se ne vanno.</li>',
'</ul>',
'<p>Un solo segnale isolato dice poco. Tre insieme, per più giorni, sono un\'indicazione chiara.</p>',
'<h2>Fermarsi non vuol dire stare fermi</h2>',
'<p>La pausa migliore raramente è il divano. Camminare, pedalare piano, fare mobilità o stretching aiuta la circolazione della linfa e accelera la rigenerazione più dell\'immobilità. È quello che si chiama recupero attivo: si toglie l\'intensità, non il movimento.</p>',
'<h2>Programmare lo scarico</h2>',
'<p>Nei percorsi ben costruiti la settimana leggera non è un imprevisto, è prevista: ogni tre o quattro settimane di carico se ne inserisce una più tranquilla. Chi la salta sistematicamente non ottiene più risultati, ne ottiene meno.</p>',
'<h2>Il carico è uno solo</h2>',
'<p>L\'organismo non tiene contabilità separate fra palestra, lavoro e vita privata. Una settimana pesante in ufficio, con poche ore di sonno, è già un carico: aggiungere sedute intense in quel periodo raramente è una buona idea. Riconoscerlo non è mancanza di disciplina, è saper leggere il quadro intero.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'il-piatto-della-longevita',
title: 'Il piatto della longevità',
excerpt: 'Un modello visivo al posto delle tabelle: metà verdure, un quarto carboidrati, un quarto proteine. Semplice da ricordare, difficile da sbagliare.',
body: p(
'<p>Le vecchie piramidi alimentari, con le loro frequenze settimanali, erano difficili da tradurre nella spesa del martedì. Un modello più semplice guarda direttamente al piatto che abbiamo davanti.</p>',
'<h2>Come si compone</h2>',
'<ul>',
'<li><strong>Metà piatto di verdura</strong>, meglio se di colori diversi nell\'arco della settimana.</li>',
'<li><strong>Un quarto di carboidrati</strong>, preferendo quelli integrali e poco lavorati.</li>',
'<li><strong>Un quarto di proteine</strong>: pesce, carne, uova, legumi, latticini.</li>',
'<li><strong>Una fonte di grassi buoni</strong>: olio extravergine, frutta secca, avocado.</li>',
'</ul>',
'<p>Non è una dieta e non prevede divieti. È uno schema che, applicato alla maggior parte dei pasti, tiene in ordine porzioni e varietà senza contare nulla.</p>',
'<h2>Due idee da smontare</h2>',
'<p><strong>I grassi non sono il nemico.</strong> Servono ad assorbire alcune vitamine, a costruire le membrane delle cellule e a produrre gli ormoni. Eliminarli fa danni. Cambia molto, invece, quali si scelgono.</p>',
'<p><strong>I carboidrati non fanno ingrassare di per sé.</strong> A parità di calorie complessive il peso dipende dal bilancio fra ciò che si introduce e ciò che si consuma. Quello che cambia è la qualità: un cereale integrale e una brioche si comportano in modo molto diverso nelle ore successive.</p>',
'<h2>La direzione generale</h2>',
'<p>Se dovessimo riassumere in una frase l\'alimentazione orientata alla longevità: nutrire molto, infiammare poco. Vuol dire più cibi veri e meno ultra-processati, più fibre, pesce con regolarità, e attenzione a zuccheri aggiunti e alcol — che restano la parte più facile da ridurre e quella che dà i risultati più visibili.</p>',
'<p>Nessuno mangia così ogni giorno, ed è normale. La media di un mese conta più del singolo pasto.</p>'
),
},
{
autore: DONATA, categoria: EDUCATIVI,
slug: 'cervello-in-allenamento',
title: 'Tenere il cervello in allenamento',
excerpt: 'Movimento, sonno, relazioni e cose nuove da imparare: le abitudini che proteggono la mente invecchiano meglio di qualsiasi integratore.',
body: p(
'<p>Parliamo molto di come mantenere il corpo e poco di come mantenere la testa, eppure il cervello risponde agli stimoli con la stessa logica del muscolo: quello che non viene usato, l\'organismo lo mette da parte.</p>',
'<h2>Età sulla carta d\'identità ed età reale</h2>',
'<p>Esiste una differenza fra l\'età anagrafica e quella biologica, cioè l\'età alla quale l\'organismo sta effettivamente funzionando. Le abitudini quotidiane spostano la seconda in entrambe le direzioni, e il cervello ha una sua velocità di invecchiamento, distinta da quella del resto del corpo.</p>',
'<h2>Che cosa lo protegge davvero</h2>',
'<ul>',
'<li><strong>Il movimento.</strong> È l\'intervento con le prove più solide: migliora l\'irrorazione cerebrale e favorisce i processi di mantenimento dei neuroni.</li>',
'<li><strong>Il sonno.</strong> Durante la notte il cervello riordina i ricordi e smaltisce le scorie prodotte di giorno. Le notti corte croniche si pagano su memoria e attenzione.</li>',
'<li><strong>Le relazioni.</strong> L\'isolamento è un fattore di rischio per il declino cognitivo, con un peso paragonabile a quello di altri fattori più discussi.</li>',
'<li><strong>Imparare cose nuove.</strong> Non i giochini di allenamento mentale, che migliorano soprattutto in quei giochini: una lingua, uno strumento, un ballo, un mestiere. Meglio se difficili.</li>',
'<li><strong>La tavola.</strong> Pesce, verdura, frutta secca, olio extravergine; poco zucchero e poco alcol.</li>',
'</ul>',
'<h2>Un\'osservazione che vale per tutto</h2>',
'<p>Queste abitudini sono le stesse che proteggono cuore, metabolismo e articolazioni. Non esistono cinque programmi diversi da seguire: esiste un modo di vivere la giornata che lavora contemporaneamente su tutti i fronti.</p>',
'<p>E vale a ogni età. Cominciare a sessant\'anni dà meno margine che cominciare a trenta, ma il margine c\'è, ed è documentato.</p>'
),
},
];
// ---------------------------------------------------------------------------
const db = new Database(DB_PATH);
function idAutore(nome) {
const esistente = db.prepare('SELECT id FROM users WHERE username = ?').get(nome);
if (esistente) return esistente.id;
// Password casuale: l'account nasce come firma, non per essere usato subito.
const hash = bcrypt.hashSync(randomBytes(12).toString('base64url'), 12);
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(nome, hash, 'user');
console.log(`Creato l'autore ${nome} (imposta una password dal pannello Utenti per usarlo).`);
return Number(res.lastInsertRowid);
}
const autori = new Map();
let inseriti = 0;
let saltati = 0;
const inserisci = db.prepare(
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, NULL, 0, @author_id, datetime('now', @quando))`
);
const tx = db.transaction(() => {
articoli.forEach((a, i) => {
if (db.prepare('SELECT 1 FROM posts WHERE slug = ?').get(a.slug)) {
saltati += 1;
return;
}
if (!autori.has(a.autore)) autori.set(a.autore, idAutore(a.autore));
inserisci.run({
title: a.title,
slug: a.slug,
category: a.categoria,
excerpt: a.excerpt,
body_html: a.body,
author_id: autori.get(a.autore),
// Pubblicazioni scaglionate a ritroso, così l'elenco non ha venti articoli alla stessa ora.
quando: `-${i * 2} days`,
});
inseriti += 1;
});
});
tx();
console.log(`Blog: ${inseriti} articoli inseriti, ${saltati} già presenti.`);
db.close();
+20
View File
@@ -28,4 +28,24 @@ echo "$tags righe con data-tag= in home (atteso >= 1)"
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true) badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)" echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
[ "$badges" = "0" ] || fail=1 [ "$badges" = "0" ] || fail=1
# /login pubblico raggiungibile
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
echo "$code /login"
[ "$code" = "200" ] || fail=1
# Header mostra "Accedi" da anonimo
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
# Nessun badge tag-badge da anonimo sulla home semplice
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
[ "$badges2" = "0" ] || fail=1
# Rotta gestione utenti protetta (redirect al login da anonimo)
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
echo "$code /admin/users (atteso 302)"
[ "$code" = "302" ] || fail=1
echo "smoke ruoli/login/inline OK"
exit $fail exit $fail
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Porta nel database i testi decisi dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
*
* Perché serve uno script e non basta il deploy: i testi mostrati dal sito vivono in
* `content_blocks`, e `resolveContent` legge **prima** il database e solo dopo il seed nel
* codice. Un tag già presente in tabella continua quindi a mostrare il vecchio valore anche
* dopo che il seed è stato aggiornato — in produzione sono 19 righe.
*
* Perché una tantum e non una migrazione all'avvio: una riscrittura a ogni restart
* rimetterebbe le mani su testi che il cliente può cambiare dal pannello. Qui si passa una
* volta, si guarda cosa è cambiato, e poi quei testi tornano suoi.
*
* Uso: node scripts/testi-17-agosto.mjs [percorso.db] [--dry-run]
*/
import Database from 'better-sqlite3';
/**
* "Small Group" diventa "Personalized Training Group", **con gli articoli giusti**.
*
* Una sostituzione meccanica produrrebbe "Lo Personalized Training Group" e "gli
* personalized training group": le due frasi che portano un articolo si correggono per
* intero, prima. Il resto è la sostituzione del nome, che resta capitalizzato come nome
* proprio anche dove prima era minuscolo ("in formula small group" → "in formula
* Personalized Training Group").
*
* Idempotente: al secondo giro non c'è più niente da sostituire.
*/
export function rinominaSmallGroup(value) {
return value
.replace(/\bLo Small Group\b/g, 'Il Personalized Training Group')
.replace(/\bgli small groups?\b/gi, 'i Personalized Training Group')
.replace(/\bsmall groups?\b/gi, 'Personalized Training Group');
}
/**
* I testi riscritti per intero, tag per tag: il cliente ne ha dettato il contenuto nuovo,
* quindi non c'è una parola da sostituire ma una frase da rimpiazzare.
*
* Sono solo i tag che in produzione esistono già con il valore vecchio. Quelli nuovi (la
* sezione «Check-up Avanzato», gli eventi, la pagina di scelta) non stanno in tabella e li
* serve il seed dal codice, senza bisogno di scriverli qui.
*/
export const TESTI = {
'service.profilazione.title': 'Check-up',
'service.profilazione.subtitle': 'Base e avanzato',
'service.profilazione.excerpt':
'Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — un’analisi completa e scientifica che guida le nostre scelte.',
'footer.services.4.label': 'Check-up',
'services.meta.description':
'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.',
};
/** I cambiamenti da fare, con il prima e il dopo. Non tocca niente. */
export function calcola(db) {
const righe = db.prepare('SELECT tag, value FROM content_blocks').all();
return righe
.map((r) => ({
tag: r.tag,
prima: r.value,
dopo: r.tag in TESTI ? TESTI[r.tag] : rinominaSmallGroup(r.value),
}))
.filter((c) => c.prima !== c.dopo);
}
export function applica(db, { dryRun = false } = {}) {
const cambi = calcola(db);
if (!dryRun && cambi.length) {
const upd = db.prepare(
"UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = 'testi-17-agosto' WHERE tag = ?",
);
db.transaction(() => { for (const c of cambi) upd.run(c.dopo, c.tag); })();
}
return cambi;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const path = args.find((a) => !a.startsWith('--')) ?? process.env.DB_PATH ?? 'data/insanitylab.db';
const db = new Database(path);
const cambi = applica(db, { dryRun });
for (const c of cambi) console.log(`${c.tag}\n - ${c.prima}\n + ${c.dopo}\n`);
console.log(`${cambi.length} contenuti ${dryRun ? 'da aggiornare (dry-run, niente scritto)' : 'aggiornati'} in ${path}`);
db.close();
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+261
View File
@@ -0,0 +1,261 @@
---
// Popup a pieno schermo con gli orari delle class in Sala Piccola.
//
// I calendari sono ricostruiti dal dato (`data/calendari.ts`) e non sono le immagini ricevute
// dal cliente: si leggono da telefono, si scaricano in PDF, e per correggere una cella non
// serve rifare uno screenshot.
//
// Si vedono tutti gli orari non ancora scaduti, uno sotto l'altro: chi guarda ad agosto vuole
// sapere com'è settembre per intero. Quelli passati spariscono da soli.
//
// Si apre da qualunque elemento con `data-apri-calendario` nella stessa pagina.
import { calendariDaMostrare, avvisiAttivi, GIORNI, DISCIPLINE, type Calendario, type Giorno } from '../data/calendari';
import { t } from '../lib/content';
const adesso = new Date();
const mostrati = calendariDaMostrare(adesso);
// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate.
const avvisi = avvisiAttivi(adesso);
const dataDi = (iso: string) => new Date(iso).toLocaleDateString('it-IT', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome',
});
// Legenda per griglia: elencare discipline che lì non compaiono farebbe cercare lezioni
// che quel periodo non ha.
const disciplinePresenti = (cal: Calendario) =>
DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
---
{mostrati.length > 0 && (
<dialog class="cal-dlg" aria-label="Calendario delle class">
<div class="cal-dlg__box">
<header class="cal-dlg__top">
<div>
<p class="cal-dlg__sala">{mostrati[0].cal.sala}</p>
<h2 class="cal-dlg__titolo">Calendario delle class</h2>
</div>
<div class="cal-dlg__azioni">
<a class="cal-dlg__btn" href="/calendario/tutti.pdf" download>{t('calendario.pdf-label')}</a>
<button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
</div>
</header>
{avvisi.length > 0 && (
<ul class="cal-avvisi">
{avvisi.map((a) => (
<li><strong>{a.quando}</strong> — {a.testo}</li>
))}
</ul>
)}
<div class="cal-dlg__scroll">
{mostrati.map(({ cal, stato }) => (
<section class="cal-blocco">
<div class="cal-blocco__top">
<h3 class="cal-blocco__titolo">{cal.titolo}</h3>
<span class:list={['cal-tag', stato === 'in-corso' && 'is-ora']}>
{stato === 'in-corso' ? 'In vigore' : `${t('calendario.futuro-label')} ${dataDi(cal.from)}`}
</span>
</div>
<table class="cal-tab">
<thead>
<tr>
<th scope="col" class="cal-tab__ora-h">Ora</th>
{GIORNI.map((g) => (
<th scope="col" class:list={['cal-tab__gg', cal.chiusi.includes(g) && 'is-chiuso']}>
{g}
{cal.noteGiorno?.[g] && <span class="cal-tab__nota">{cal.noteGiorno[g]}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{cal.righe.map((r) => (
<tr class:list={[r.clou && 'is-clou']}>
<th scope="row" class="cal-tab__ora">{r.ora}</th>
{GIORNI.map((g: Giorno) => {
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
return (
<td class:list={['cal-tab__cella', chiuso && 'is-chiuso', lezione && `is-${lezione.toLowerCase()}`]}>
{chiuso ? <span class="cal-tab__chiuso-txt">{t('calendario.chiuso-label')}</span> : lezione ?? ''}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<ul class="cal-legenda">
{disciplinePresenti(cal).map((d) => (
<li class={`is-${d.toLowerCase()}`}>{d}</li>
))}
</ul>
</section>
))}
</div>
<footer class="cal-dlg__giu">
<p class="cal-dlg__nota">{t('calendario.nota')}</p>
</footer>
</div>
</dialog>
)}
<script>
// Apertura/chiusura del popup. Nessuna libreria: <dialog> nativo dà da solo il fondale, il
// focus trap e la chiusura con Esc.
const dlg = document.querySelector<HTMLDialogElement>('.cal-dlg');
if (dlg) {
document.querySelectorAll<HTMLElement>('[data-apri-calendario]').forEach((el) => {
el.addEventListener('click', (e) => { e.preventDefault(); dlg.showModal(); });
});
dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close());
// Click sul fondale: il target è il dialog stesso solo quando si preme fuori dal riquadro.
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
// "Scarica in PDF" è un link a /calendario/tutti.pdf, cioè un file vero generato dal
// server, un orario per pagina: si salva sul telefono e si gira su WhatsApp. Il CSS di
// stampa più sotto resta per chi preme Ctrl+P col popup aperto.
}
</script>
<style is:global>
.cal-dlg {
width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0;
border: 0; background: transparent; color: var(--c-text);
}
.cal-dlg::backdrop { background: rgba(43,43,43,.75); }
.cal-dlg__box {
display: flex; flex-direction: column; height: 100%; background: var(--c-bg);
padding: 28px clamp(16px, 4vw, 48px) 24px;
}
.cal-dlg__top { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
.cal-dlg__sala {
margin: 0; font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
letter-spacing: .25em; text-transform: uppercase; color: var(--c-accent-dark);
}
.cal-dlg__titolo { margin: 6px 0 0; font-size: 1.5rem; }
.cal-dlg__azioni { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.cal-dlg__btn {
display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0;
cursor: pointer; padding: 13px 22px; background: var(--c-accent); color: #fff;
transition: background .2s;
}
.cal-dlg__btn:hover { background: var(--c-accent-dark); }
.cal-dlg__chiudi {
width: 42px; height: 42px; border: 1px solid #ddd; background: #fff; cursor: pointer;
font-size: 1.4rem; line-height: 1; color: var(--c-heading);
}
.cal-dlg__chiudi:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
/* Giornate fuori griglia: stanno sopra le tabelle perché sono l'eccezione a ciò che le
tabelle dicono, e sotto passerebbero per una postilla. */
.cal-avvisi {
list-style: none; margin: 18px 0 0; padding: 12px 16px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); display: flex; flex-direction: column; gap: 4px;
}
.cal-avvisi li { font-size: .84rem; color: var(--c-text); }
.cal-avvisi strong { color: var(--c-heading); }
.cal-dlg__scroll { flex: 1; overflow: auto; margin: 18px 0 0; }
.cal-blocco + .cal-blocco { margin-top: 34px; padding-top: 26px; border-top: 1px solid #e4e0d9; }
.cal-blocco__top { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; }
.cal-blocco__titolo { margin: 0; font-size: 1.05rem; }
.cal-tag {
font-family: var(--font-heading); font-size: .62rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; padding: 4px 9px; background: var(--c-bg-alt); color: var(--c-text-light);
}
/* L'orario valido adesso si distingue a colpo d'occhio da quello che comincerà dopo: senza,
due griglie affiancate si leggono come alternative fra cui scegliere. */
.cal-tag.is-ora { background: var(--c-accent); color: #fff; }
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
/* `table-layout: fixed` e' quello che rende le sette colonne dei giorni ESATTAMENTE
uguali: in automatico il browser le dimensiona sul contenuto, cosi' il sabato con la
nota «solo il 12 settembre» diventava largo il doppio e i giorni vuoti si stringevano.
Con il layout fisso basta dare una larghezza alla colonna delle ore: tutto il resto
viene diviso in parti uguali fra le sette rimanenti. E' lo stesso criterio del PDF,
dove le colonne sono `(larghezzaUtile - larghezzaOra) / 7` da sempre.
⚠️ Il `min-width` e' passato da 720 a 780px INSIEME al layout fisso, e i due sono
legati: a colonne uguali la piu' stretta deve contenere la parola piu' lunga, mentre
in layout automatico il browser allargava da solo le colonne piene e stringeva quelle
vuote. Il conto, misurato sul Montserrat 400 che il sito serve davvero:
REFORMER a .7rem con letter-spacing .1em = 75,4 px
colonna a 720px = (720 - 64) / 7 = 93,7 px, meno 20 di padding e 1 di bordo = 72,7
cioe' 2,7 px in meno del necessario: la parola sarebbe uscita dalla cella sul
telefono, dove il minimo e' quello che si vede. A 780px la colonna da' 81,3 px di
spazio utile, ~6 di margine. Chi tocca padding, corpo o spaziatura rifaccia il conto. */
.cal-tab { border-collapse: collapse; width: 100%; min-width: 780px; table-layout: fixed; }
.cal-tab th, .cal-tab td { border: 1px solid #e4e0d9; padding: 9px 10px; text-align: left; }
.cal-tab thead th {
background: var(--c-bg-alt);
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; color: var(--c-heading);
}
.cal-tab__nota {
display: block; font-size: .62rem; letter-spacing: .06em; text-transform: none;
color: var(--c-text-light); font-weight: 400;
}
/* L'unica larghezza dichiarata: la colonna delle ore. Resta piu' stretta dei giorni,
come nel PDF — ci sta dentro «12.15» e nient'altro. */
.cal-tab__ora-h, .cal-tab__ora { width: 64px; }
.cal-tab__ora-h { color: var(--c-accent-dark) !important; }
.cal-tab__ora {
background: var(--c-bg);
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; color: var(--c-heading);
white-space: nowrap;
}
.cal-tab tr.is-clou .cal-tab__ora { background: #f7f2e6; }
.cal-tab__cella { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; color: var(--c-heading); }
/* Ogni disciplina ha un fondo tenue e una barra a sinistra: la barra si vede anche quando il
browser stampa senza sfondi. */
.cal-tab__cella.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
.cal-tab__cella.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
.cal-tab__cella.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
.cal-tab__cella.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
.cal-tab__cella.is-chiuso { background: repeating-linear-gradient(135deg, #f5f3f0, #f5f3f0 6px, #ece8e2 6px, #ece8e2 12px); }
.cal-tab__chiuso-txt { font-size: .62rem; letter-spacing: .12em; color: var(--c-text-light); }
.cal-tab thead th.is-chiuso { color: var(--c-text-light); }
.cal-legenda { display: flex; flex-wrap: wrap; gap: 8px 16px; list-style: none; margin: 12px 0 0; padding: 0; }
.cal-legenda li {
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
color: var(--c-heading);
}
.cal-legenda li.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
.cal-legenda li.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
.cal-legenda li.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
.cal-legenda li.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
.cal-dlg__giu { padding-top: 16px; }
.cal-dlg__nota { margin: 0; font-size: .8rem; color: var(--c-text-light); }
/* Stampa dalla pagina (Ctrl+P col popup aperto): resta solo il calendario, un orario per
foglio. Il bottone scarica invece il PDF generato dal server, che è il documento buono da
girare. `visibility` invece di `display` perché il dialog è annidato nel corpo della
pagina e nascondere gli antenati nasconderebbe anche lui. */
@media print {
@page { size: A4 landscape; margin: 10mm; }
body { visibility: hidden; }
.cal-dlg, .cal-dlg * { visibility: visible; }
.cal-dlg { position: absolute; inset: 0; height: auto; }
.cal-dlg::backdrop { background: transparent; }
.cal-dlg__box { padding: 0; height: auto; }
.cal-dlg__azioni { display: none; }
.cal-dlg__scroll { overflow: visible; }
.cal-blocco { break-inside: avoid; }
.cal-blocco + .cal-blocco { break-before: page; border-top: 0; padding-top: 0; }
.cal-tab { min-width: 0; }
.cal-tab th, .cal-tab td { padding: 5px 6px; }
.cal-tab__cella { font-size: .62rem; }
}
@media (max-width: 600px) {
.cal-dlg__box { padding: 20px 14px 18px; }
.cal-dlg__titolo { font-size: 1.2rem; }
.cal-dlg__btn { padding: 11px 16px; letter-spacing: .12em; }
}
</style>
+9 -3
View File
@@ -14,15 +14,21 @@ const { variant = 'full' } = Astro.props;
data-msg-error-network={t('form.msg.error-network')} data-msg-error-network={t('form.msg.error-network')}
> >
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" /> <input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
{variant === 'full' && <input class="field" name="lastName" placeholder={t('form.field.last-name')} maxlength="100" />} <input class="field" name="lastName" placeholder={t('form.field.last-name')} required maxlength="100" />
{variant === 'full' && <input class="field" name="phone" placeholder={t('form.field.phone')} maxlength="40" />} <input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" /> <input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea> <textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" /> <input class="hp" type="text" name="hp_field" tabindex="-1" autocomplete="off" aria-hidden="true" />
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button> <button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
<p class="form-msg" hidden></p> <p class="form-msg" hidden></p>
</form> </form>
<style>
.cform__privacy { font-size: .74rem; opacity: .8; margin: 8px 0 0; line-height: 1.5; }
.cform__privacy a { text-decoration: underline; }
</style>
<script> <script>
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => { document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
+11 -9
View File
@@ -1,6 +1,5 @@
--- ---
import { site } from '../data/site'; import { site } from '../data/site';
import ContactForm from './ContactForm.astro';
import { t, contentImageUrl } from '../lib/content'; import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro'; import T from './content/T.astro';
const year = new Date().getFullYear(); const year = new Date().getFullYear();
@@ -11,9 +10,10 @@ const year = new Date().getFullYear();
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" /> <img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
<T tag="footer.tagline" as="p" class="ftr__tag" /> <T tag="footer.tagline" as="p" class="ftr__tag" />
<T tag="footer.about" as="p" /> <T tag="footer.about" as="p" />
<p>{t('global.contact.phone')}<br />{t('global.contact.address')}, {t('global.contact.city-line')}</p> <p>{t('global.contact.address')}, {t('global.contact.city-line')}<br />{t('global.contact.phone')}<br /><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
<p class="ftr__hours">{t('global.hours.1')}</p> <p class="ftr__hours">{t('global.hours.1')}</p>
<p class="ftr__hours">{t('global.hours.2')}</p> <p class="ftr__hours">{t('global.hours.2')}</p>
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
</div> </div>
<nav aria-label="training"> <nav aria-label="training">
<T tag="footer.col.training-title" as="h4" /> <T tag="footer.col.training-title" as="h4" />
@@ -23,14 +23,10 @@ const year = new Date().getFullYear();
<T tag="footer.col.services-title" as="h4" /> <T tag="footer.col.services-title" as="h4" />
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)} {site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav> </nav>
<div>
<T tag="footer.col.contact-title" as="h4" />
<ContactForm variant="footer" />
</div>
</div> </div>
<div class="ftr__bar"> <div class="ftr__bar">
<div class="container ftr__bar-in"> <div class="container ftr__bar-in">
<p>© {year} <T tag="footer.copyright" as="span" /></p> <p>© {year} <T tag="footer.copyright" as="span" /> <span class="ftr__sep">·</span> <a href="/privacy">Privacy Policy</a> <span class="ftr__sep">·</span> <a href="/cookie-policy">Cookie Policy</a></p>
<p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p> <p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div> </div>
</div> </div>
@@ -41,12 +37,18 @@ const year = new Date().getFullYear();
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; } .ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
.ftr a:hover { color: #fff; } .ftr a:hover { color: #fff; }
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; } .ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
.ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; } .ftr__grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr; gap: 40px; padding-block: 26px; text-align: center; align-items: start; }
/* Il logo è display:block (reset globale): lo centro nella colonna */
.ftr__grid img { margin-inline: auto; }
/* Training e Servizi allineati alla riga "Promuoviamo il movimento..." (logo+tagline ~110px) */
.ftr__grid nav { padding-top: 110px; }
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; } .ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
.ftr__hours { margin: 0; } .ftr__hours { margin: 0; }
.ftr__legal { margin-top: 14px; font-size: .78rem; opacity: .8; }
.ftr__bar-in .ftr__sep { margin: 0 4px; opacity: .5; }
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; } .ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; } .ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; } .ftr__bar-in a { display: inline; margin-left: 12px; }
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } .ftr__grid nav { padding-top: 0; } }
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } } @media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
</style> </style>
+94 -1
View File
@@ -1,16 +1,54 @@
--- ---
import { site } from '../data/site'; import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content'; import { t, contentImageUrl } from '../lib/content';
import { landingFor } from '../lib/auth';
import { linkConsulenza } from '../data/consulenza';
interface Props {
user: { username: string; role: string } | null;
showTags: boolean;
canTags: boolean;
}
const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname; const path = Astro.url.pathname;
// ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo
// sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che
// esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta.
// ⚠️ L'indirizzo vero, oggi: apps.insanitylab.it NON esiste (manca il record su Aruba) e
// puntarci scriverebbe nel menu del sito un link morto. Quando quel record ci sara', qui
// si cambia una riga — ed e' l'unico posto del sito che nomina l'area riservata.
const AREA_RISERVATA = 'https://piattaforme.tielogic.xyz';
const areaHref = user ? landingFor(user.role) : '/admin';
// Consulenza con Nicola su ogni pagina, non solo sulla Reformer Class (Adriano 30/09, #22).
const consulenzaHref = linkConsulenza(t('global.consulenza.url'));
--- ---
<header class="hdr" id="site-header"> <header class="hdr" id="site-header">
<div class="container hdr__in"> <div class="container hdr__in">
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a> <a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale"> <nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => ( {site.navigation.map((item, i) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a> (!('adminOnly' in item && item.adminOnly) || user?.role === 'admin') && (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
)
))} ))}
<a class="hdr__link hdr__consulenza-mob" href={consulenzaHref} target="_blank" rel="noopener noreferrer">{t('global.consulenza.cta-breve')}</a>
</nav> </nav>
<a class="hdr__consulenza" href={consulenzaHref} target="_blank" rel="noopener noreferrer">{t('global.consulenza.cta-breve')}</a>
<div class="hdr__user">
{user ? (
<details class="umenu">
<summary class="umenu__btn">{user.username} ▾</summary>
<div class="umenu__panel">
<a href={areaHref}>Area riservata</a>
{canTags && (
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
)}
<a href="/admin/logout">Esci</a>
</div>
</details>
) : (
<a class="hdr__login" href={AREA_RISERVATA}>Area riservata</a>
)}
</div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false"> <button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</button> </button>
@@ -22,8 +60,22 @@ const path = Astro.url.pathname;
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); } .hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); } .hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
.hdr__nav { display: flex; gap: 34px; } .hdr__nav { display: flex; gap: 34px; }
/* Col bottone Consulenza nella barra le voci non devono andare a capo (#22). */
@media (min-width: 992px) { .hdr__nav { gap: 22px; } .hdr__link, .hdr__login { white-space: nowrap; } .hdr__logo { flex-shrink: 0; margin-right: 28px; } }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; } .hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); } .hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__link--bio { color: var(--c-accent-dark); }
/* Menu Piattaforme: su desktop è un pannello che scende dalla voce, in mobile —
dove la nav è già una colonna — le voci si aprono in linea sotto il titolo. */
.plat { position: relative; }
.plat__btn { cursor: pointer; list-style: none; }
.plat__btn::-webkit-details-marker { display: none; }
.plat__panel {
position: absolute; left: 0; top: calc(100% + 14px); background: #fff; min-width: 210px;
box-shadow: 0 8px 22px rgba(0,0,0,.12); display: flex; flex-direction: column; padding: 8px 0; z-index: 200;
}
.plat__item { padding: 10px 16px; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.plat__item:hover, .plat__item.is-active { background: #f4f2ef; color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; } .hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; } .hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) { @media (max-width: 991px) {
@@ -31,7 +83,25 @@ const path = Astro.url.pathname;
.hdr__nav.is-open { display: flex; } .hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; } .hdr__link { padding: 12px 0; }
.hdr__burger { display: block; } .hdr__burger { display: block; }
.plat__panel { position: static; box-shadow: none; padding: 0 0 8px; min-width: 0; }
.plat__item { padding: 10px 0 10px 14px; }
} }
.hdr__consulenza { font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; text-decoration: none; color: #fff; background: #6b5d5b; padding: 10px 14px; margin: 0 18px; white-space: nowrap; transition: background .2s; }
.hdr__consulenza:hover { background: var(--c-accent-dark); }
.hdr__consulenza-mob { display: none; }
@media (max-width: 991px) {
.hdr__consulenza { display: none; }
.hdr__consulenza-mob { display: block; color: var(--c-accent-dark); }
}
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__login:hover { color: var(--c-accent-dark); }
.umenu { position: relative; }
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
.umenu__btn::-webkit-details-marker { display: none; }
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
</style> </style>
<script> <script>
@@ -43,4 +113,27 @@ const path = Astro.url.pathname;
const open = nav.classList.toggle('is-open'); const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open)); toggle.setAttribute('aria-expanded', String(open));
}); });
// Il pannello Piattaforme è un <details>: da solo resterebbe aperto anche cliccando altrove.
const plat = document.querySelector<HTMLDetailsElement>('.plat');
if (plat) {
document.addEventListener('click', (e) => {
if (plat.open && !plat.contains(e.target as Node)) plat.open = false;
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') plat.open = false; });
}
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
</script> </script>
+2 -2
View File
@@ -7,5 +7,5 @@ const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined; const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
--- ---
{c.type === 'html' {c.type === 'html'
? <Tag data-tag={tag} class={classes} set:html={c.value} /> ? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
: <Tag data-tag={tag} class={classes}>{c.value}</Tag>} : <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
+6 -4
View File
@@ -1,14 +1,16 @@
--- ---
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { getImageOverride } from '../../lib/content'; import { resolveContent } from '../../lib/content';
interface Props { interface Props {
tag: string; src: ImageMetadata; alt: string; tag: string; src: ImageMetadata; alt: string;
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number; class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
} }
const { tag, src, alt, class: cls, ...rest } = Astro.props; const { tag, src, alt, class: cls, ...rest } = Astro.props;
const override = getImageOverride(tag); const c = resolveContent(tag);
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
const guid = c.guid || undefined;
--- ---
{override {override
? <img src={override} alt={alt} class={cls} data-tag={tag} loading={rest.loading} /> ? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
: <Image src={src} alt={alt} class={cls} data-tag={tag} {...rest} />} : <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
+12 -16
View File
@@ -1,10 +1,9 @@
--- ---
import hero1 from '../../assets/img/hero-1.png'; // Foto hero rimossa ma conservata su disco per riuso futuro: assets/img/hero-1.png
import T from '../content/T.astro'; import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
const slides = [ const slides = [
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'], image: hero1, alt: 'Atleti InsanityLab' }, { titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'] },
]; ];
--- ---
<section class="hero" aria-label="presentazione"> <section class="hero" aria-label="presentazione">
@@ -15,7 +14,6 @@ const slides = [
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1> <h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/services">{t('home.hero.cta')}</a> <a class="btn" href="/services">{t('home.hero.cta')}</a>
</div> </div>
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
</div> </div>
</div> </div>
))} ))}
@@ -34,30 +32,28 @@ const slides = [
.hero__slide.is-active { display: block; } .hero__slide.is-active { display: block; }
/* Come nel Figma: figure a piena altezza ancorate in basso a destra, /* Come nel Figma: figure a piena altezza ancorate in basso a destra,
testo sovrapposto sulla sinistra. */ testo sovrapposto sulla sinistra. */
.hero__in { position: relative; display: flex; align-items: center; min-height: calc(100vh - var(--header-h)); } .hero__in { position: relative; display: flex; align-items: center; min-height: 0; padding-block: 90px; }
.hero__line { display: block; } /* Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
.hero__text { position: relative; z-index: 1; max-width: 46%; } di questo componente: si ancora a .hero__text (che il cid ce l'ha) + :global. */
.hero__text h1 :global(.hero__line) { display: block; }
.hero__text { position: relative; z-index: 1; max-width: 640px; }
.hero__text h1 { font-weight: 500; } .hero__text h1 { font-weight: 500; }
.hero__img { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; } .hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; } .hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
@media (max-width: 991px) { @media (max-width: 991px) {
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; } .hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
.hero__text { max-width: none; } .hero__text { max-width: none; }
.hero__img { position: static; max-width: 100%; height: auto; }
} }
/* Ingresso al load stile Slider Revolution del template */ /* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } } @keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
@keyframes hero-side { from { opacity: 0; transform: translateX(60px); } to { opacity: 1; transform: none; } } :global(html.js) .hero__text h1 :global(.hero__line) { opacity: 0; animation: hero-up .8s ease forwards; }
:global(html.js) .hero__line { opacity: 0; animation: hero-up .8s ease forwards; } :global(html.js) .hero__text h1 :global(.hero__line:nth-child(1)) { animation-delay: .15s; }
:global(html.js) .hero__line:nth-child(1) { animation-delay: .15s; } :global(html.js) .hero__text h1 :global(.hero__line:nth-child(2)) { animation-delay: .3s; }
:global(html.js) .hero__line:nth-child(2) { animation-delay: .3s; } :global(html.js) .hero__text h1 :global(.hero__line:nth-child(3)) { animation-delay: .45s; }
:global(html.js) .hero__line:nth-child(3) { animation-delay: .45s; }
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; } :global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
:global(html.js) .hero__line, :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; } :global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn { opacity: 1; animation: none; }
} }
</style> </style>
+17 -2
View File
@@ -25,16 +25,31 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
</div> </div>
))} ))}
</div> </div>
<div class="method__cta" data-reveal>
<a class="btn btn--dark" href="/contact"><T tag="home.method.cta" as="span" /></a>
</div>
</div> </div>
</section> </section>
<style> <style>
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; } .method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
.method__cta { text-align: center; margin-top: 48px; }
.method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; } .method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
.method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; } .method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
.method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; } /* :global(img) perché gli <img> di override resi da <TImg> non ereditano il cid
del componente: senza questo la regola scoped non li raggiungerebbe. */
.method__circle :global(img) { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; } .method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; } .method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
.method__step p { font-size: .85rem; color: var(--c-text-light); } .method__step p { font-size: .85rem; color: var(--c-text-light); }
@media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } } @media (max-width: 767px) {
/* una colonna con lo step in orizzontale: a due colonne i titoli lunghi
si spezzavano su quattro righe. */
.method__row { grid-template-columns: 1fr; gap: 28px; text-align: left; }
.method__step { display: grid; grid-template-columns: 104px 1fr; column-gap: 18px; align-items: start; }
.method__circle { width: 104px; height: 104px; margin: 0; grid-row: 1 / span 2; }
.method__circle span { font-size: 1.8rem; top: -4px; left: -4px; }
.method__step h3 { margin-top: 4px; }
.method__note { text-align: left; margin-left: 0; max-width: none; }
}
</style> </style>
+5
View File
@@ -16,4 +16,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
.pstrip { background: var(--c-accent); padding-block: 40px; } .pstrip { background: var(--c-accent); padding-block: 40px; }
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; } .pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
.pstrip img { height: 44px; width: auto; opacity: .9; } .pstrip img { height: 44px; width: auto; opacity: .9; }
@media (max-width: 767px) {
.pstrip { padding-block: 28px; }
.pstrip__in { justify-content: center; gap: 20px 28px; }
.pstrip img { height: 30px; }
}
</style> </style>
+6 -1
View File
@@ -2,6 +2,11 @@
import { services } from '../../data/services'; import { services } from '../../data/services';
import { serviceIcons as icons } from '../../data/service-icons'; import { serviceIcons as icons } from '../../data/service-icons';
import T from '../content/T.astro'; import T from '../content/T.astro';
// Stessa convenzione di /services: le voci con grid:false (Small Group, Rehab,
// Holistic Class, Reformer Class) sono approfondimenti dei riquadri Training e
// non compaiono nella griglia Servizi.
const gridServices = services.filter((p) => p.grid !== false);
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
@@ -10,7 +15,7 @@ import T from '../content/T.astro';
<T tag="home.services.body" as="p" /> <T tag="home.services.body" as="p" />
</div> </div>
<div class="pgrid"> <div class="pgrid">
{services.map((p, i) => ( {gridServices.map((p, i) => (
<a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}> <a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
<T tag={`service.${p.slug}.title`} as="h3" /> <T tag={`service.${p.slug}.title`} as="h3" />
+13 -3
View File
@@ -4,6 +4,10 @@ import { trainings } from '../../data/trainings';
import T from '../content/T.astro'; import T from '../content/T.astro';
import TImg from '../content/TImg.astro'; import TImg from '../content/TImg.astro';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
// `items` permette di mostrare solo una parte dei training (le viste di /training):
// senza, la fascia di card resterebbe l'elenco intero sopra ai blocchi filtrati.
interface Props { items?: readonly (typeof trainings)[number][] }
const { items = trainings } = Astro.props;
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
const tones = ['taupe', 'tan', 'dark', 'charcoal']; const tones = ['taupe', 'tan', 'dark', 'charcoal'];
@@ -11,10 +15,10 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1; const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
--- ---
<section class="tcards"> <section class="tcards">
{trainings.map((tr, i) => { {items.map((tr, i) => {
const img = imgOf(tr.image); const img = imgOf(tr.image);
const card = ( const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}> <div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
<T tag={`training.${tr.id}.title`} as="h3" /> <T tag={`training.${tr.id}.title`} as="h3" />
<T tag={`training.${tr.id}.caption`} as="p" /> <T tag={`training.${tr.id}.caption`} as="p" />
<a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a> <a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
@@ -46,5 +50,11 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
.tcards__photo { overflow: hidden; } .tcards__photo { overflow: hidden; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; } .tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
.tcards__photo:hover .tcards__img { transform: scale(1.06); } .tcards__photo:hover .tcards__img { transform: scale(1.06); }
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 767px) {
.tcards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tcards__cell { padding: 22px 16px; }
.tcards__cell h3 { font-size: 1rem; letter-spacing: .08em; }
.tcards__cell p { font-size: .74rem; }
.tcards__link { font-size: .62rem; letter-spacing: .14em; margin-top: 12px; }
}
</style> </style>
+195
View File
@@ -0,0 +1,195 @@
// Calendari delle class in Sala Piccola.
//
// Trascritti dai due fogli forniti dal cliente il 10/08/2026 (immagini su Wasabi, cartella
// del progetto). Sono dato e non immagini di proposito: così la griglia si legge da telefono,
// si stampa in PDF e si corregge una cella senza rifare uno screenshot.
//
// Ogni calendario porta la sua finestra di validità: la pagina mostra quello in vigore, e
// quando nessuno lo è ancora mostra il prossimo dichiarando da quando entra. Il passaggio da
// un orario all'altro avviene da solo, senza deploy — stesso principio delle edizioni della
// pagina promo (`data/promo-edizioni.ts`).
export const GIORNI = ['LUN', 'MAR', 'MER', 'GIO', 'VEN', 'SAB', 'DOM'] as const;
export type Giorno = (typeof GIORNI)[number];
/** Le discipline della sala: il colore di ognuna è definito nel componente del calendario. */
export const DISCIPLINE = ['REFORMER', 'PILATES', 'YOGA', 'MOBILITY'] as const;
export type Disciplina = (typeof DISCIPLINE)[number];
export interface RigaCalendario {
ora: string;
/** Fascia messa in risalto nel foglio del cliente (le righe gialle). */
clou?: boolean;
celle: Partial<Record<Giorno, Disciplina>>;
}
export interface Calendario {
id: string;
/** Come lo chiama il cliente: è anche l'etichetta mostrata in pagina. */
titolo: string;
sala: string;
/** Validità, ISO con offset esplicito (il server gira in UTC). `to: null` = a tempo indeterminato. */
from: string;
to: string | null;
/** Giorni in cui la sala è chiusa: colonna barrata, non vuota — vuoto e chiuso non sono lo stesso. */
chiusi: Giorno[];
/** Precisazione su una colonna, es. il sabato aperto una volta sola. */
noteGiorno?: Partial<Record<Giorno, string>>;
righe: RigaCalendario[];
}
export const calendari: Calendario[] = [
{
id: 'settembre-1-18',
titolo: "Dall'1 al 18 settembre",
sala: 'Sala piccola',
from: '2026-09-01T00:00:00+02:00',
to: '2026-09-18T23:59:59+02:00',
chiusi: ['DOM'],
noteGiorno: { SAB: 'solo il 12 settembre' },
// ⚠️ Il VENERDI' non ha nessuna class in questo periodo, ed e' voluto: le tre che
// c'erano (MOBILITY 8.30, REFORMER 9.15 e 10.00) sono state tolte su richiesta del
// cliente l'01/09/2026, ed erano tutte di mattina. Non e' una trascrizione monca:
// confrontando questa griglia con quella dal 21 settembre — dove il venerdi' c'e' —
// la differenza salta all'occhio e sembra un errore. Non lo e'.
// La colonna resta VUOTA e non barrata: togliere le class non vuol dire che la sala
// sia chiusa, e nessuno ce l'ha detto (scelta di Adriano, 01/09).
righe: [
{ ora: '6.15', celle: {} },
{ ora: '7.00', clou: true, celle: { MAR: 'YOGA' } },
{ ora: '7.45', clou: true, celle: { MAR: 'PILATES', MER: 'REFORMER' } },
{ ora: '8.30', celle: { MER: 'REFORMER' } },
{ ora: '9.15', celle: { SAB: 'MOBILITY' } },
{ ora: '10.00', celle: { SAB: 'PILATES' } },
{ ora: '10.45', celle: { SAB: 'REFORMER' } },
{ ora: '11.30', celle: { SAB: 'REFORMER' } },
{ ora: '12.15', clou: true, celle: { MAR: 'REFORMER', MER: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '13.00', clou: true, celle: { MAR: 'REFORMER', MER: 'PILATES', GIO: 'REFORMER' } },
{ ora: '13.45', celle: {} },
{ ora: '14.30', celle: { MAR: 'REFORMER' } },
{ ora: '15.15', celle: { MAR: 'MOBILITY' } },
{ ora: '16.00', celle: {} },
{ ora: '16.45', celle: { MAR: 'REFORMER' } },
{ ora: '17.30', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '18.15', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.00', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.45', clou: true, celle: { MAR: 'REFORMER', GIO: 'PILATES' } },
],
},
{
id: 'settembre-21-in-poi',
titolo: 'Dal 21 settembre in poi',
sala: 'Sala piccola',
from: '2026-09-21T00:00:00+02:00',
to: null,
chiusi: ['DOM'],
righe: [
{ ora: '6.15', celle: { MER: 'MOBILITY' } },
{ ora: '7.00', clou: true, celle: { MAR: 'YOGA', MER: 'REFORMER' } },
{ ora: '7.45', clou: true, celle: { MAR: 'PILATES', MER: 'REFORMER', VEN: 'PILATES' } },
{ ora: '8.30', celle: { MAR: 'REFORMER', MER: 'REFORMER', VEN: 'MOBILITY' } },
{ ora: '9.15', celle: { VEN: 'REFORMER', SAB: 'MOBILITY' } },
{ ora: '10.00', celle: { VEN: 'REFORMER', SAB: 'PILATES' } },
{ ora: '10.45', celle: { SAB: 'REFORMER' } },
{ ora: '11.30', celle: { GIO: 'YOGA', SAB: 'REFORMER' } },
{ ora: '12.15', clou: true, celle: { MAR: 'REFORMER', MER: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '13.00', clou: true, celle: { MAR: 'REFORMER', MER: 'PILATES', GIO: 'REFORMER' } },
{ ora: '13.45', celle: {} },
{ ora: '14.30', celle: { MAR: 'REFORMER' } },
{ ora: '15.15', celle: { MAR: 'MOBILITY' } },
{ ora: '16.00', celle: {} },
{ ora: '16.45', celle: { MAR: 'REFORMER' } },
// Mercoledì 17.30 e 18.15 sono vuote: nel foglio ricevuto le due celle erano coperte
// dal riquadro "Opzioni Incolla" di Excel, e il 17/08/2026 Adriano ha confermato che
// lì non c'è niente. Il vuoto qui è una risposta, non una lettura mancata.
{ ora: '17.30', clou: true, celle: { LUN: 'PILATES', MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '18.15', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.00', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'REFORMER' } },
{ ora: '19.45', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'PILATES' } },
],
},
];
/**
* Giornate che non stanno in nessuna griglia: eventi e chiusure.
*
* Il 19 e il 20 settembre non compaiono in nessuno dei due fogli, e la ragione non è una
* dimenticanza — il sabato c'è la presentazione della nuova apertura e la domenica si sta
* chiusi (detto da Adriano il 17/08/2026). Scritto qui, quel vuoto smette di sembrare un
* errore di trascrizione e diventa un'informazione per chi legge gli orari.
*/
export interface AvvisoCalendario {
id: string;
/** `null` = visibile da subito. */
from: string | null;
/** Ultimo istante in cui ha senso mostrarlo: dopo, sparisce da solo. */
to: string;
quando: string;
testo: string;
}
export const avvisi: AvvisoCalendario[] = [
{
id: 'presentazione-19-settembre',
from: null,
to: '2026-09-19T23:59:59+02:00',
quando: 'Sabato 19 settembre',
testo: 'Presentazione della nuova apertura: nessuna class in programma.',
},
{
id: 'chiusura-20-settembre',
from: null,
to: '2026-09-20T23:59:59+02:00',
quando: 'Domenica 20 settembre',
testo: 'Chiuso.',
},
];
/** Gli avvisi ancora validi, in ordine di scadenza: i più imminenti per primi. */
export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): AvvisoCalendario[] {
const t = now.getTime();
return list
.filter((a) => (a.from === null || t >= Date.parse(a.from)) && t <= Date.parse(a.to))
.sort((x, y) => Date.parse(x.to) - Date.parse(y.to));
}
/**
* Le schede servizio da cui si apre il calendario della sala.
*
* Sono le due class che si fanno davvero: la Performance Class è uscita di scena il
* 17/08/2026 (il cliente non la eroga più) e con lei la sua scheda.
*/
export const PAGINE_CON_CALENDARIO = ['holistic-class', 'reformer-class'];
/**
* Le card della griglia /services che portano il bottone del calendario.
*
* È un elenco a parte, e non un doppione del precedente: nella griglia la card si chiama
* «Class» ma ha slug `performance-class`, e da lì si va alla vista Training con Holistic e
* Reformer. Il calendario è lo stesso, il punto da cui si apre no.
*/
export const CARD_CON_CALENDARIO = ['performance-class'];
export interface CalendarioMostrato {
cal: Calendario;
stato: 'in-corso' | 'futuro';
}
/**
* I calendari da mostrare: quello in vigore e quelli che verranno, in ordine di partenza.
*
* Si vedono **tutti insieme**, non solo quello del momento: chi guarda a fine agosto vuole
* sapere com'è settembre per intero, e vedere una griglia sola gli farebbe credere che sia
* quella per sempre. Ognuna porta il proprio stato, così l'orario in vigore adesso non si
* confonde con quello che comincerà dopo.
*
* Gli orari **scaduti spariscono da soli**: dal 21 settembre resta il solo foglio valido, e
* nessuno rischia di leggere le lezioni di un periodo finito.
*/
export function calendariDaMostrare(now: Date, list: Calendario[] = calendari): CalendarioMostrato[] {
const t = now.getTime();
return list
.filter((c) => c.to === null || t <= Date.parse(c.to))
.sort((a, b) => Date.parse(a.from) - Date.parse(b.from))
.map((cal) => ({ cal, stato: t >= Date.parse(cal.from) ? 'in-corso' : 'futuro' }));
}
+15
View File
@@ -0,0 +1,15 @@
// Consulenza conoscitiva gratuita con Nicola (prenotazione su Google Calendar).
//
// Il link vive nel pannello contenuti (tag `global.consulenza.url`): qui c'è il valore di
// partenza e la guardia. Il tag è modificabile da admin, quindi si accetta solo https.
export const CONSULENZA_URL_DEFAULT = 'https://calendar.app.google/nWXMGD2S5g2pVeeD7';
/** Slug delle pagine servizio che mostrano il bottone di prenotazione. */
export const PAGINE_CON_CONSULENZA = ['reformer-class'];
/** Link da usare: il valore del pannello se è https, altrimenti quello di default
* (niente javascript:, http:, vuoto). */
export function linkConsulenza(valore: string): string {
const v = (valore ?? '').trim();
return v.startsWith('https://') ? v : CONSULENZA_URL_DEFAULT;
}
+321 -16
View File
@@ -7,13 +7,14 @@ import { partners } from './partners';
import { disciplines, days, slots } from './agenda'; import { disciplines, days, slots } from './agenda';
import { services } from './services'; import { services } from './services';
import { performanceClassPricing } from './pricing'; import { performanceClassPricing } from './pricing';
import { CONSULENZA_URL_DEFAULT } from './consulenza';
export type ContentType = 'text' | 'html' | 'image'; export type ContentType = 'text' | 'html' | 'image';
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] } export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style']): SeedEntry => const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style', 'align']): SeedEntry =>
({ tag, type: 'text', value, styleOptions }); ({ tag, type: 'text', value, styleOptions });
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): SeedEntry => const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'align']): SeedEntry =>
({ tag, type: 'html', value, styleOptions }); ({ tag, type: 'html', value, styleOptions });
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' }); const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
@@ -23,6 +24,8 @@ export const contentSeed: SeedEntry[] = [
T('global.contact.email', site.email, []), T('global.contact.email', site.email, []),
T('global.contact.address', site.address, []), T('global.contact.address', site.address, []),
T('global.contact.city-line', site.cityLine, []), T('global.contact.city-line', site.cityLine, []),
T('global.contact.map-query', site.mapQuery, []),
T('global.whatsapp', '351 753 5977', []),
T('global.hours.1', site.hours[0], []), T('global.hours.1', site.hours[0], []),
T('global.hours.2', site.hours[1], []), T('global.hours.2', site.hours[1], []),
T('global.nav.1.label', site.navigation[0].label, []), T('global.nav.1.label', site.navigation[0].label, []),
@@ -30,6 +33,7 @@ export const contentSeed: SeedEntry[] = [
T('global.nav.3.label', site.navigation[2].label, []), T('global.nav.3.label', site.navigation[2].label, []),
T('global.nav.4.label', site.navigation[3].label, []), T('global.nav.4.label', site.navigation[3].label, []),
T('global.nav.5.label', site.navigation[4].label, []), T('global.nav.5.label', site.navigation[4].label, []),
T('global.nav.6.label', site.navigation[5].label, []),
// --- Header --- // --- Header ---
T('header.logo.alt', 'InsanityLab', []), T('header.logo.alt', 'InsanityLab', []),
@@ -48,6 +52,8 @@ export const contentSeed: SeedEntry[] = [
T('footer.training.2.label', site.footerTraining[1].label, []), T('footer.training.2.label', site.footerTraining[1].label, []),
T('footer.training.3.label', site.footerTraining[2].label, []), T('footer.training.3.label', site.footerTraining[2].label, []),
T('footer.training.4.label', site.footerTraining[3].label, []), T('footer.training.4.label', site.footerTraining[3].label, []),
T('footer.training.5.label', site.footerTraining[4].label, []),
T('footer.training.6.label', site.footerTraining[5].label, []),
T('footer.services.1.label', site.footerServices[0].label, []), T('footer.services.1.label', site.footerServices[0].label, []),
T('footer.services.2.label', site.footerServices[1].label, []), T('footer.services.2.label', site.footerServices[1].label, []),
T('footer.services.3.label', site.footerServices[2].label, []), T('footer.services.3.label', site.footerServices[2].label, []),
@@ -136,6 +142,7 @@ export const contentSeed: SeedEntry[] = [
// --- Home: metodo (data-driven da method.ts) --- // --- Home: metodo (data-driven da method.ts) ---
H('home.method.title', 'Il metodo<br />InsanityLab'), H('home.method.title', 'Il metodo<br />InsanityLab'),
T('home.method.cta', 'Contattaci', []),
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')), H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
T('method.extra-note', methodExtraNote, []), T('method.extra-note', methodExtraNote, []),
...methodSteps.flatMap((s) => [ ...methodSteps.flatMap((s) => [
@@ -159,7 +166,7 @@ export const contentSeed: SeedEntry[] = [
// --- About: meta, hero --- // --- About: meta, hero ---
T('about.meta.title', 'Insanitylab', []), T('about.meta.title', 'Insanitylab', []),
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []), T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
T('about.hero.title', 'Insanitylab', []), T('about.hero.title', 'About us', []),
// --- About: mission --- // --- About: mission ---
T('about.mission.title', 'Mission'), T('about.mission.title', 'Mission'),
@@ -176,14 +183,14 @@ export const contentSeed: SeedEntry[] = [
// --- About: about --- // --- About: about ---
T('about.about.title', 'About'), T('about.about.title', 'About'),
T('about.about.body', "InsanityLab nasce con l'obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all'interno di un approccio personalizzato e consapevole."), T('about.about.body', "InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."),
I('about.about.image'), I('about.about.image'),
// --- About: convenzioni --- // --- About: convenzioni ---
T('about.convenzioni.title', 'Convenzioni'), T('about.convenzioni.title', 'Convenzioni'),
T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."), T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."),
T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati, con percorsi individuali, small group training e classi di gruppo seguiti da professionisti qualificati.'), T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'),
T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'), T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'),
T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."), T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."),
T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []), T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
I('about.convenzioni.image'), I('about.convenzioni.image'),
@@ -201,7 +208,7 @@ export const contentSeed: SeedEntry[] = [
// --- About: studios --- // --- About: studios ---
T('about.studios.title', 'In-Sanity Studios'), T('about.studios.title', 'In-Sanity Studios'),
T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse."), T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."),
T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."), T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."),
T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'), T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'),
I('about.studios.image'), I('about.studios.image'),
@@ -216,7 +223,7 @@ export const contentSeed: SeedEntry[] = [
T('about.storylab.title', 'Storylab'), T('about.storylab.title', 'Storylab'),
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'), T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
T('about.timeline.label', 'INSANITYLAB 2021-2024', []), T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."), T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come i Personalized Training Group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."),
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []), T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []), T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
@@ -225,9 +232,8 @@ export const contentSeed: SeedEntry[] = [
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
// --- Services: lista (/services) --- // --- Services: lista (/services) ---
T('services.meta.title', 'Servizi', []), T('services.meta.title', 'Servizi', []),
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', []), T('services.meta.description', 'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.', []),
T('services.hero.title', 'Servizi', []), T('services.hero.title', 'Servizi', []),
T('services.intro.title', 'Servizi'),
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."), T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
T('services.card.more-label', 'Per saperne di più →', []), T('services.card.more-label', 'Per saperne di più →', []),
@@ -242,6 +248,13 @@ export const contentSeed: SeedEntry[] = [
T(`service.${p.slug}.faq.${i + 1}.q`, item.q), T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
T(`service.${p.slug}.faq.${i + 1}.a`, item.a), T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
]), ]),
...(p.extra
? [
T(`service.${p.slug}.extra.title`, p.extra.title),
T(`service.${p.slug}.extra.body`, p.extra.body),
...p.extra.items.map((it, i) => T(`service.${p.slug}.extra.item.${i + 1}`, it)),
]
: []),
I(`service.${p.slug}.image`), I(`service.${p.slug}.image`),
]), ]),
@@ -257,6 +270,11 @@ export const contentSeed: SeedEntry[] = [
T('service.pricing.entries-singular', 'ingresso settimanale', []), T('service.pricing.entries-singular', 'ingresso settimanale', []),
T('service.pricing.entries-plural', 'ingressi settimanali', []), T('service.pricing.entries-plural', 'ingressi settimanali', []),
T('service.pricing.buy-cta', 'Acquista', []), T('service.pricing.buy-cta', 'Acquista', []),
T('service.contact-cta', 'Contattaci', []),
T('service.consulenza-cta', 'Prenota una consulenza conoscitiva gratuita', []),
T('service.consulenza-note', '30-45 minuti con Nicola, in sede: via Leandro Alberti 76, primo piano', []),
T('global.consulenza.url', CONSULENZA_URL_DEFAULT, []),
T('global.consulenza.cta-breve', 'Consulenza gratuita', []),
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) --- // --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
T('pricing.heading', performanceClassPricing.heading), T('pricing.heading', performanceClassPricing.heading),
@@ -272,14 +290,10 @@ export const contentSeed: SeedEntry[] = [
// --- Training: meta, hero, intro, trial --- // --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []), T('training.meta.title', 'Training', []),
T('training.meta.description', 'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.', []), T('training.meta.description', 'One to One, Personalized Training Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
T('training.hero.title', 'training', []), T('training.hero.title', 'training', []),
T('training.intro.title', 'Training'), T('training.tutti.cta-label', 'Vedi tutti i training', []),
T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'), T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'),
T('training.trial.title', 'Prenota la lezione di prova'),
T('training.trial.body', "La lezione di prova è l'occasione perfetta per conoscere il metodo, vivere l'esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova."),
T('training.trial.cta', 'Prenota', []),
I('training.trial.image'),
// --- Contact --- // --- Contact ---
T('contact.meta.title', 'Contatti', []), T('contact.meta.title', 'Contatti', []),
@@ -293,6 +307,29 @@ export const contentSeed: SeedEntry[] = [
T('contact.info.email-label', 'E-mail'), T('contact.info.email-label', 'E-mail'),
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'), T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
// --- Promo ed eventi: scelta (/promo-eventi) ---
T('promo-eventi.meta.title', 'Promo ed eventi', []),
T('promo-eventi.meta.description', 'Le promozioni in corso e gli eventi di InsanityLab, a partire dall’inaugurazione del nuovo spazio.', []),
T('promo-eventi.hero.title', 'Promo ed eventi', []),
T('promo-eventi.intro', 'Scegli cosa vuoi vedere: le promozioni attive in questo momento oppure gli eventi in programma al centro.'),
T('promo-eventi.promo.label', 'Promo'),
T('promo-eventi.promo.desc', 'I pacchetti e le offerte del periodo, con prezzi e condizioni.'),
T('promo-eventi.promo.nessuna', 'Nessuna promozione in corso', []),
T('promo-eventi.promo.cta', 'Vedi le promo', []),
T('promo-eventi.eventi.label', 'Eventi'),
T('promo-eventi.eventi.desc', 'Le date da segnare: aperture, presentazioni e appuntamenti in sede.'),
T('promo-eventi.eventi.cta', 'Vedi gli eventi', []),
// --- Eventi (/promo-eventi/eventi) ---
T('eventi.meta.title', 'Eventi', []),
T('eventi.meta.description', 'Gli eventi InsanityLab: inaugurazione del nuovo spazio il 19 settembre 2026, con aperitivo e dj set.', []),
T('eventi.hero.title', 'Eventi', []),
T('eventi.inaugurazione.title', 'Inaugurazione'),
T('eventi.inaugurazione.date', '19 settembre 2026'),
T('eventi.inaugurazione.body', 'Apriamo le porte del nuovo spazio con un aperitivo e dj set. Sarà l’occasione per conoscere lo staff, visitare la struttura e prenotare una prova gratuita dei nostri servizi. Il programma completo della giornata sarà pubblicato a breve.'),
T('eventi.cta.label', 'Scrivici su WhatsApp', []),
T('eventi.torna.label', '← Torna a promo ed eventi', []),
// --- 404 --- // --- 404 ---
T('notfound.meta.title', 'Pagina non trovata', []), T('notfound.meta.title', 'Pagina non trovata', []),
T('notfound.eyebrow', 'Errore 404', []), T('notfound.eyebrow', 'Errore 404', []),
@@ -300,6 +337,274 @@ export const contentSeed: SeedEntry[] = [
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'), T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
T('notfound.cta', 'Torna alla home', []), T('notfound.cta', 'Torna alla home', []),
// --- Promo ed eventi (promo-eventi.astro) ---
// Hero
T('promo.hero.badge', 'insanity.lab · bologna savena'),
T('promo.hero.title1', 'Allenati con metodo.'),
T('promo.hero.accent', 'Scegli come'),
T('promo.hero.title2', 'vuoi farlo.'),
H('promo.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
T('promo.hero.cta', 'Le nostre proposte', []),
// Launch
T('promo.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
T('promo.launch.title', 'Il nostro metodo nasce per durare.'),
T('promo.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
T('promo.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte."),
// Perché 5
T('promo.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
// Label bottone acquisto (condivisa dalle 4 card)
T('promo.buy-label', 'Acquista', []),
// Card 1 — One to One
T('promo.card.1.eyebrow', 'Personal training', []),
T('promo.card.1.title', 'One to One'),
T('promo.card.1.price', '200 €', []),
T('promo.card.1.price-old', '300 €', []),
T('promo.card.1.unit', '5 incontri', []),
T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
// Card 2 — Personalized Training Group (in evidenza)
T('promo.card.2.badge', 'Più scelto', []),
T('promo.card.2.eyebrow', 'Personal training', []),
T('promo.card.2.title', 'Personalized Training Group'),
T('promo.card.2.price', '125 €', []),
T('promo.card.2.price-old', '220 €', []),
T('promo.card.2.unit', '5 incontri', []),
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('promo.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('promo.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
T('promo.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
// Card 3 — Reformer
T('promo.card.3.eyebrow', 'Pilates class', []),
T('promo.card.3.title', 'Reformer'),
T('promo.card.3.price', '115 €', []),
T('promo.card.3.price-old', '140 €', []),
T('promo.card.3.unit', '5 incontri', []),
T('promo.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
T('promo.card.3.feature.2', 'Lavoro a basso impatto sulle articolazioni'),
T('promo.card.3.feature.3', 'Percorso breve, non lezioni isolate'),
// Card 4 — Holistic class
T('promo.card.4.eyebrow', 'Holistic class', []),
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
T('promo.card.4.price', '75 €', []),
T('promo.card.4.price-old', '100 €', []),
T('promo.card.4.unit', '5 incontri', []),
T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
T('promo.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
T('promo.card.4.feature.3', 'Adatto a tutti i livelli'),
// Countdown alla scadenza della promo. `deadline` è una data ISO con offset: la pagina la
// legge, la confronta col tempo del browser e nasconde tutto il blocco quando è passata
// (o se il valore non è una data valida). Editabile dal pannello per la finestra successiva.
T('promo.countdown.label', 'La promo scade tra', []),
T('promo.countdown.deadline', '2026-08-19T23:59:59+02:00', []),
T('promo.countdown.unit.days', 'giorni', []),
T('promo.countdown.unit.hours', 'ore', []),
T('promo.countdown.unit.minutes', 'minuti', []),
T('promo.countdown.unit.seconds', 'secondi', []),
// Nota validità
T('promo.validity-note', 'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
// FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp
T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
// La FAQ 0 è mostrata per prima: numerata così per non rinumerare le esistenti (i tag già
// presenti nei DB non verrebbero riscritti dall'INSERT OR IGNORE del seed).
T('promo.faq.0.q', 'Come faccio ad acquistare i pacchetti?'),
H('promo.faq.0.a', 'Clicca sull\'icona del pacchetto che desideri acquistare e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona quello che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'),
H('promo.faq.1.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'),
H('promo.faq.2.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class saranno pubblicati entro Settembre. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Personalized Training Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'),
T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('promo.faq.5.q', 'Il pagamento è sicuro?'),
H('promo.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
// --- Calendario delle class (popup dalle pagine servizio) ---
// La griglia è dato (`data/calendari.ts`); qui ci sono solo le etichette attorno.
T('calendario.link-label', 'Calendario', []),
T('calendario.pdf-label', 'Scarica in PDF', []),
T('calendario.chiudi-label', 'Chiudi', []),
T('calendario.futuro-label', 'In vigore dal', []),
T('calendario.chiuso-label', 'Chiuso', []),
T('calendario.nota', 'Gli orari possono subire variazioni. Le lezioni si prenotano dall\'app Bsport.'),
// --- Pagina promo, stato "nessuna promozione in corso" ---
// Fra un'edizione e la successiva la pagina resta raggiungibile (è nel menu e nei link già
// girati ai clienti): mostra questo invece dei prezzi di una promozione finita.
T('promo.chiusa.title', 'Nessuna promozione in corso'),
H('promo.chiusa.desc', 'Le offerte di apertura sono terminate.<br>Scrivici su WhatsApp: ti raccontiamo i percorsi attivi e troviamo quello giusto per te.'),
T('promo.chiusa.cta', 'Scrivici su WhatsApp', []),
// --- Opening Bundles (edizione `bundle-apertura`, dal 20 agosto al 4 settembre 2026) ---
// Testi dal volantino fornito dal cliente il 13/08. La struttura delle card (domanda,
// descrizione, riquadro date) è dichiarata in `data/promo-edizioni.ts`.
T('bundle.hero.badge', 'New Opening · Offerta esclusiva'),
T('bundle.hero.title1', 'Opening Bundles.'),
T('bundle.hero.accent', 'Non è uno sconto.'),
T('bundle.hero.title2', 'È un accesso privilegiato.'),
H('bundle.hero.desc', 'Tre percorsi costruiti su misura, disponibili solo per la nuova apertura.<br>Acquistabili fino al 4 settembre, utilizzabili dal 1° settembre al 1° dicembre.'),
T('bundle.hero.cta', 'Scopri i tre percorsi', []),
T('bundle.buy-label', 'Acquista', []),
T('bundle.card.1.eyebrow', 'Personal Training', []),
T('bundle.card.1.title', 'Intensive'),
T('bundle.card.1.question', 'Ti alleni ma non vedi risultati?'),
T('bundle.card.1.price', '450 €', []),
T('bundle.card.1.price-old', '770 €', []),
T('bundle.card.1.feature.1', 'Profilazione Avanzata'),
T('bundle.card.1.feature.2', '8 sessioni PT 1:1'),
T('bundle.card.1.feature.3', '4 Community Class'),
T('bundle.card.1.desc', 'Un check-up avanzato per conoscere davvero il tuo corpo — poi 2 sessioni di Personal Training e una Community Class a settimana. Un mese per capire di cosa sei capace.'),
T('bundle.card.1.note-label', 'Periodo di utilizzo', []),
H('bundle.card.1.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
T('bundle.card.2.eyebrow', 'Pilates & Holistic', []),
T('bundle.card.2.title', 'Restore'),
T('bundle.card.2.question', 'Hai dolori, rigidità o ti senti bloccato?'),
T('bundle.card.2.price', '170 €', []),
T('bundle.card.2.price-old', '220 €', []),
T('bundle.card.2.feature.1', '4 Pilates Reformer'),
T('bundle.card.2.feature.2', '4 Holistic Class'),
T('bundle.card.2.desc', 'Se hai dolori, rigidità, o semplicemente vuoi prenderti cura di te con dolcezza. Reformer e Holistic Class lavorano insieme per rimettere il corpo in equilibrio — mobilità, core, respiro. Senza forzare, senza improvvisare.'),
T('bundle.card.2.note-label', 'Periodo di utilizzo', []),
H('bundle.card.2.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
T('bundle.card.3.eyebrow', 'Training & Wellness', []),
T('bundle.card.3.title', 'Recharge'),
T('bundle.card.3.question', 'Sei stanco e svuotato anche dopo una notte di sonno?'),
T('bundle.card.3.price', '250 €', []),
T('bundle.card.3.price-old', '310 €', []),
T('bundle.card.3.feature.1', 'Analisi HRV'),
T('bundle.card.3.feature.2', '4 Pilates Reformer'),
T('bundle.card.3.feature.3', '3 Training Group'),
T('bundle.card.3.desc', 'Stanco, svuotato, fuori ritmo. Attraverso l\'analisi HRV misuriamo il tuo livello di stress e il tuo stato energetico reale — poi Reformer e Training Group per farti ripartire davvero.'),
T('bundle.card.3.note-label', 'Periodo di utilizzo', []),
H('bundle.card.3.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
// La deadline è la stessa fine dell'edizione in `promo-edizioni.ts`: un test lo verifica,
// perché un countdown che scade a un'ora diversa dallo scambio fa contraddire il sito.
T('bundle.countdown.label', 'I bundle scadono tra', []),
T('bundle.countdown.deadline', '2026-09-04T23:59:59+02:00', []),
T('bundle.countdown.unit.days', 'giorni', []),
T('bundle.countdown.unit.hours', 'ore', []),
T('bundle.countdown.unit.minutes', 'minuti', []),
T('bundle.countdown.unit.seconds', 'secondi', []),
T('bundle.validity-note', 'Acquistabili fino al 4 settembre 2026 · Utilizzabili dal 1° settembre al 1° dicembre 2026'),
T('bundle.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('bundle.faq.0.q', 'Come faccio ad acquistare un bundle?'),
H('bundle.faq.0.a', 'Clicca sul bundle che desideri e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona il bundle che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('bundle.faq.1.q', 'Entro quando posso acquistarlo, e entro quando devo usarlo?'),
H('bundle.faq.1.a', 'I bundle si acquistano <span class="hl">fino al 4 settembre 2026</span> e si utilizzano <span class="hl">dal 1° settembre al 1° dicembre 2026</span>. Dopo l\'acquisto scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> per fissare il primo incontro: verrai ricontattato subito tramite messaggio o chiamata.'),
T('bundle.faq.2.q', 'Posso scambiare gli incontri fra un bundle e l\'altro?'),
H('bundle.faq.2.a', 'No: ogni bundle è un percorso costruito su un obiettivo preciso, e le attività che contiene lavorano insieme. Se ti interessano servizi di bundle diversi puoi acquistarne più di uno, oppure scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> e ti aiutiamo a scegliere.'),
T('bundle.faq.3.q', 'Posso regalare un bundle a qualcun altro?'),
H('bundle.faq.3.a', 'Sì. Puoi acquistare il bundle a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('bundle.faq.4.q', 'Il pagamento è sicuro?'),
H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
// --- Promo settembre (edizione `settembre-2026`, 5 → 19 settembre) ---
// Testi ripresi dall'edizione di agosto, che il cliente ha già approvato: cambia solo ciò che
// le date o le nuove creative rendono falso. I prezzi e le voci degli elenchi vengono dalle
// sei creative consegnate il 03/09.
T('settembre.hero.badge', 'insanity.lab · bologna savena'),
T('settembre.hero.title1', 'Allenati con metodo.'),
T('settembre.hero.accent', 'Scegli come'),
T('settembre.hero.title2', 'vuoi farlo.'),
H('settembre.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
T('settembre.hero.cta', 'Le nostre proposte', []),
T('settembre.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
T('settembre.launch.title', 'Il nostro metodo nasce per durare.'),
T('settembre.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
T('settembre.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte — e una valutazione singola, per chi vuole partire dai propri numeri."),
T('settembre.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
T('settembre.buy-label', 'Acquista', []),
T('settembre.card.1.eyebrow', 'Personal training', []),
T('settembre.card.1.title', 'One to One'),
T('settembre.card.1.price', '250 €', []),
T('settembre.card.1.unit', '5 incontri', []),
T('settembre.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('settembre.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('settembre.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
T('settembre.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
T('settembre.card.2.eyebrow', 'Personal training', []),
T('settembre.card.2.title', 'Personalized Training Group'),
T('settembre.card.2.price', '150 €', []),
T('settembre.card.2.unit', '5 incontri', []),
T('settembre.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('settembre.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('settembre.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
T('settembre.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
T('settembre.card.3.eyebrow', 'Pilates class', []),
T('settembre.card.3.title', 'Reformer Class'),
T('settembre.card.3.price', '130 €', []),
T('settembre.card.3.unit', '5 incontri', []),
T('settembre.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
T('settembre.card.3.feature.2', '40 minuti, massimo 5 utenti'),
T('settembre.card.3.feature.3', 'Lavoro a basso impatto sulle articolazioni'),
T('settembre.card.4.eyebrow', 'Holistic class', []),
T('settembre.card.4.title', 'Pilates Mat, Mobility, Yoga'),
T('settembre.card.4.price', '80 €', []),
T('settembre.card.4.unit', '5 incontri', []),
T('settembre.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
T('settembre.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
T('settembre.card.4.feature.3', 'Adatto a tutti i livelli'),
// Il Check-up avanzato non è un percorso: si compra una volta e si fa in un appuntamento.
// Per questo l'unità dice "1 valutazione" e non "5 incontri".
T('settembre.card.5.eyebrow', 'Valutazione', []),
T('settembre.card.5.title', 'Check-up avanzato'),
T('settembre.card.5.price', '70 €', []),
T('settembre.card.5.unit', '1 valutazione', []),
T('settembre.card.5.feature.1', 'Analisi completa di composizione corporea, e analisi HRV'),
T('settembre.card.5.feature.2', 'Spirometria e scansione posturale 3D'),
T('settembre.card.5.feature.3', 'Report dettagliato e consigli personalizzati'),
// La deadline è lo stesso istante in cui finisce l'edizione in `promo-edizioni.ts`: un test
// lo verifica, perché un countdown che scade a un'ora diversa dallo scambio fa contraddire
// il sito.
T('settembre.countdown.label', 'La promo scade tra', []),
T('settembre.countdown.deadline', '2026-09-19T23:59:59+02:00', []),
T('settembre.countdown.unit.days', 'giorni', []),
T('settembre.countdown.unit.hours', 'ore', []),
T('settembre.countdown.unit.minutes', 'minuti', []),
T('settembre.countdown.unit.seconds', 'secondi', []),
T('settembre.validity-note', 'Promo valida dal 5 al 19 settembre 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
T('settembre.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('settembre.faq.0.q', 'Come faccio ad acquistare i pacchetti?'),
H('settembre.faq.0.a', 'Clicca sull\'icona del pacchetto che desideri acquistare e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona quello che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('settembre.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'),
H('settembre.faq.1.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
// Ad agosto qui c'era scritto "gli orari saranno pubblicati entro Settembre": adesso è
// settembre e il calendario esiste, quindi la frase andava riscritta o diventava falsa.
T('settembre.faq.2.q', 'Come funziona il percorso Reformer Class o Holistic Class?'),
H('settembre.faq.2.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class li trovi nel <span class="hl">calendario</span>, che puoi consultare e scaricare in PDF dalla pagina <span class="hl">Servizi</span> del sito. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('settembre.faq.7.q', "Che cos'è il Check-up avanzato?"),
H('settembre.faq.7.a', 'È una <span class="hl">valutazione singola</span>, non un percorso a incontri: analisi completa della composizione corporea e analisi HRV, spirometria e scansione posturale 3D. Al termine ricevi un report dettagliato con i consigli che ne derivano. Puoi farlo da solo, oppure come punto di partenza prima di scegliere un percorso. Per fissare l\'appuntamento scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span>.'),
T('settembre.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
H('settembre.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Personalized Training Group</span>, <span class="hl">Reformer Class</span> o <span class="hl">Pilates Mat, Mobility, Yoga</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno. Il <span class="hl">Check-up avanzato</span> è a sé: è una valutazione singola e non un pacchetto di incontri.'),
T('settembre.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
H('settembre.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('settembre.faq.5.q', 'Il pagamento è sicuro?'),
H('settembre.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
T('settembre.faq.6.q', 'Posso chiedere un rimborso?'),
H('settembre.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
]; ];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+4 -4
View File
@@ -2,8 +2,8 @@ export const methodIntro = "Il Metodo Insanitylab si basa su un percorso struttu
export const methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard."; export const methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard.";
export const methodSteps = [ export const methodSteps = [
{ n: 1, title: "Prima consulenza conoscitiva iniziale", text: "Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.", extra: false, image: "method-step-1" }, { n: 1, title: "Prima consulenza conoscitiva iniziale", text: "La base di partenza per definire le priorità del percorso di ciascun cliente.", extra: false, image: "method-step-1" },
{ n: 2, title: "Prima seduta operativa", text: "In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.", extra: false, image: "method-step-2" }, { n: 2, title: "Check-up fisico", text: "Profilazione fisica per definire lo stato di salute iniziale del cliente.", extra: false, image: "method-step-2" },
{ n: 3, title: "Plicometria", text: "Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.", extra: true, image: "method-step-3" }, { n: 3, title: "Insanity plan", text: "Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.", extra: true, image: "method-step-3" },
{ n: 4, title: "Analisi antropometriche", text: "Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.", extra: true, image: "method-step-4" }, { n: 4, title: "Monitoraggio costante", text: "Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.", extra: true, image: "method-step-4" },
]; ];
+122
View File
@@ -0,0 +1,122 @@
// Edizioni della pagina "Promo ed eventi".
//
// Una promozione ha una fine, e la pagina che la mostra deve saperla: qui sta scritto quale
// set di contenuti è in vetrina e in quale finestra di tempo. La pagina non decide niente,
// legge l'edizione attiva nell'istante della richiesta — così il passaggio da una promozione
// alla successiva avviene da solo, alla mezzanotte giusta, senza deploy e senza che qualcuno
// debba ricordarsene.
//
// I testi restano nei tag (`<prefix>.*`, modificabili senza deploy). Qui c'è solo la
// struttura: quante card, quali campi ha ciascuna, quali FAQ. Aggiungere un'edizione =
// una voce in questo elenco + i suoi tag nel seed; i test in `tests/promo-edizioni.test.ts`
// verificano che non manchi niente e che le finestre non si sovrappongano né lascino buchi.
export interface CardSpec {
/** Numero della card: forma i tag `<prefix>.card.<id>.*`. */
id: number;
/** Quante voci ha l'elenco puntato (`feature.1` … `feature.N`). */
features: number;
/** Card in evidenza: cornice diversa e badge `card.<id>.badge`. */
featured?: boolean;
/** Riga sotto il prezzo, es. "5 incontri". */
unit?: boolean;
/** Prezzo pieno barrato accanto a quello promo. Senza, la card mostra il solo prezzo di
* vendita: un listino barrato non dichiarato dal cliente sarebbe un numero inventato. */
priceOld?: boolean;
/** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */
question?: boolean;
/** Paragrafo descrittivo sotto l'elenco. */
desc?: boolean;
/** Riquadro in fondo col periodo di acquisto e di utilizzo. */
note?: boolean;
}
export interface Edizione {
id: string;
/** Prefisso dei tag di contenuto di questa edizione. */
prefix: string;
/** Inizio e fine validità, ISO **con offset esplicito**: il server gira in UTC e senza
* offset lo scambio avverrebbe due ore prima di quanto scritto sul sito. */
from: string;
to: string;
cards: CardSpec[];
/** Numeri delle FAQ, nell'ordine di comparsa. */
faq: number[];
/** Sezioni "launch" e "why5" fra l'hero e le card. */
launch?: boolean;
}
export const edizioni: Edizione[] = [
{
// Promo di lancio: 4 pacchetti da 5 incontri, online dal 1° agosto.
id: 'agosto-2026',
prefix: 'promo',
from: '2026-08-01T00:00:00+02:00',
to: '2026-08-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true, priceOld: true },
{ id: 2, features: 4, unit: true, priceOld: true, featured: true },
{ id: 3, features: 3, unit: true, priceOld: true },
{ id: 4, features: 3, unit: true, priceOld: true },
],
faq: [0, 1, 2, 3, 4, 5, 6],
},
{
// Opening Bundles: subentra alla promo agosto il giorno dopo la sua scadenza, si
// acquistano fino al 4 settembre e si usano dal 1° settembre al 1° dicembre.
id: 'bundle-apertura',
prefix: 'bundle',
from: '2026-08-20T00:00:00+02:00',
to: '2026-09-04T23:59:59+02:00',
cards: [
{ id: 1, features: 3, question: true, desc: true, note: true, priceOld: true },
{ id: 2, features: 2, question: true, desc: true, note: true, priceOld: true },
// Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli
// dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato.
{ id: 3, features: 3, question: true, desc: true, note: true, priceOld: true },
],
faq: [0, 1, 2, 3, 4],
},
{
// Promo di settembre: subentra ai bundle il 5 e chiude il 19, il giorno dell'inaugurazione.
// Torna la forma dei pacchetti da 5 incontri, con in più il Check-up avanzato, che è una
// valutazione singola e non un percorso — per questo ha "1 valutazione" al posto di
// "5 incontri".
// Nessun prezzo barrato: le creative del cliente danno il solo prezzo promo, e un listino
// pieno ripreso dall'edizione di agosto sarebbe un numero che nessuno ha confermato.
id: 'settembre-2026',
prefix: 'settembre',
from: '2026-09-05T00:00:00+02:00',
to: '2026-09-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true },
{ id: 2, features: 4, unit: true },
{ id: 3, features: 3, unit: true },
{ id: 4, features: 3, unit: true },
// Nessuna card "in evidenza": cinque proposte per esigenze diverse, non cinque livelli
// dello stesso prodotto.
{ id: 5, features: 3, unit: true },
],
// La 7 è la domanda nuova sul Check-up: va letta dopo le altre due "come funziona", non
// in fondo dopo il rimborso, quindi l'ordine di comparsa non segue la numerazione.
faq: [0, 1, 2, 7, 3, 4, 5, 6],
},
];
/** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */
export const tagEdizione = (e: Edizione, suffisso: string): string => `${e.prefix}.${suffisso}`;
/**
* L'edizione valida in un istante, o `null` se non ce n'è nessuna (e allora la pagina mostra
* lo stato "nessuna promozione in corso" invece di prezzi scaduti).
*
* Il confronto è fra istanti assoluti: `Date.parse` interpreta l'offset scritto nel dato, e
* `now` arriva dall'orologio del server. Nessuna delle due parti dipende dal fuso della
* macchina, che in produzione è UTC e in sviluppo no.
*/
export function edizioneAttiva(now: Date, list: Edizione[] = edizioni): Edizione | null {
const t = now.getTime();
return list.find((e) => t >= Date.parse(e.from) && t <= Date.parse(e.to)) ?? null;
}
+8 -2
View File
@@ -1,9 +1,15 @@
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar // Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
export const serviceIcons: Record<string, string> = { export const serviceIcons: Record<string, string> = {
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4', 'one-to-one': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'small-group': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z', 'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0', 'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z', 'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4', 'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
// Servizi rinominati (modifiche 02): profilazione (grafico/analisi), osteopatia (mani)
'profilazione': 'M4 20V6m5 14V4m5 16v-8m5 8V9M3 20h18',
'osteopatia': 'M8 3v7a4 4 0 008 0V3M6 14c0 4 3 7 6 7s6-3 6-7',
// Riquadri Training con pagina dettaglio
'holistic-class': 'M12 3a3 3 0 110 6 3 3 0 010-6zm0 6v7m-4 3l4-3 4 3',
'reformer-class': 'M3 8h18M3 16h18M6 8v8m12-8v8M9 12h6',
}; };
+126 -26
View File
@@ -1,22 +1,41 @@
export interface Service { export interface Service {
slug: string; slug: string;
title: string; title: string;
/** Override del titolo mostrato nell'hero (H1) della pagina dettaglio.
* Se assente usa `title`. Serve quando l'hero deve differire dall'H2/card. */
heroTitle?: string;
subtitle: string; subtitle: string;
excerpt: string; excerpt: string;
longDescription: string; longDescription: string;
features: string[]; features: string[];
faq: { q: string; a: string }[]; faq: { q: string; a: string }[];
hasPricing: boolean; hasPricing: boolean;
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
grid?: boolean;
/** Dove porta la card (foto e "Per saperne di più"). Assente = la scheda del servizio.
* Serve per le card che instradano a una vista di /training invece che a una scheda. */
href?: string;
/** false = nessuna pagina dettaglio: /services/<slug> risponde 404.
* La voce resta in griglia con il proprio `href`. */
page?: boolean;
/** Sezione in coda alla scheda, oltre alla descrizione principale.
* Tag: service.<slug>.extra.title | .body | .item.N */
extra?: { title: string; body: string; items: string[] };
} }
export const services: Service[] = [ export const services: Service[] = [
{ {
slug: "personal-training", slug: "one-to-one",
title: "Personal Training", title: "One to One",
subtitle: "One to One / Small Group", // La card di /services porta alla vista Training con One to One, Personalized Training
excerpt: "Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.", // Group e Rehab: è come il cliente vuole instradare chi cerca l'allenamento seguito.
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.", href: "/training/one-to-one",
features: ["Trainer dedicato", "Percorso su misura", "Valutazione iniziale e monitoraggio", "Formula one to one o small group (max 3)"], heroTitle: "Personal Training",
subtitle: "Allenamento con un trainer dedicato",
excerpt: "L’allenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati.",
features: [],
faq: [ faq: [
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." }, { q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." }, { q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
@@ -25,8 +44,29 @@ export const services: Service[] = [
hasPricing: false, hasPricing: false,
}, },
{ {
slug: "small-group",
title: "Personalized Training Group",
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
excerpt: "Il Personalized Training Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.",
longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.",
features: [],
faq: [
{ q: "Quante persone ci sono in un Personalized Training Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
],
hasPricing: false,
grid: false,
},
{
// La Performance Class non si fa più (cliente, 17/08/2026): la scheda che la spiegava
// sparisce (`page: false` → /services/performance-class risponde 404) mentre la card
// "Class" resta in griglia e porta alla vista Training con Holistic e Reformer.
// Il pricing è caduto con la scheda: era l'unico servizio ad averlo.
slug: "performance-class", slug: "performance-class",
title: "Performance Class", title: "Class",
href: "/training/class",
page: false,
subtitle: "Gruppo max 8 persone", subtitle: "Gruppo max 8 persone",
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.", excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.", longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
@@ -39,7 +79,7 @@ export const services: Service[] = [
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." }, { q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
{ q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." }, { q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
], ],
hasPricing: true, hasPricing: false,
}, },
{ {
slug: "coaching", slug: "coaching",
@@ -56,30 +96,44 @@ export const services: Service[] = [
hasPricing: false, hasPricing: false,
}, },
{ {
slug: "pilates-flow", // NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
title: "Pilates Flow", // Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
subtitle: "Movimento e controllo", slug: "profilazione",
excerpt: "Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre l’osteopatia supporta recupero, prevenzione e benessere globale integrandosi con l’allenamento.", title: "Check-up",
longDescription: "Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con l’osteopatia, supporta recupero, prevenzione e benessere globale all’interno del percorso di allenamento.", subtitle: "Base e avanzato",
features: ["Postura e core", "Movimento fluido e controllo", "Integrazione con osteopatia", "Benessere globale"], excerpt: "Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — un’analisi completa e scientifica che guida le nostre scelte.",
longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.",
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
faq: [ faq: [
{ q: "A chi è adatto il Pilates Flow?", a: "A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello." }, { q: "Quando si fa la profilazione?", a: "All’inizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
{ q: "Serve esperienza precedente?", a: "No: le progressioni sono graduali e seguite da professionisti qualificati." },
{ q: "Come si integra con l’osteopatia?", a: "Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento." },
], ],
hasPricing: false, hasPricing: false,
// Il testo della scheda descrive il Check-up Base; l'Avanzato è la parte aggiunta il
// 17/08/2026 su richiesta del cliente, con la Wellness Tower e i sensori HRV.
extra: {
title: "Check-up Avanzato",
body: "Con la Wellness Tower, tecnologia di ultima generazione, andiamo oltre: bioimpedenza, scansione posturale 3D e spirometria restituiscono un quadro completo e oggettivo del tuo corpo. A completare il quadro, misuriamo anche l’HRV attraverso sensori dedicati — un dato chiave per capire come il tuo corpo recupera e si adatta al carico di lavoro. Ogni dato raccolto guida scelte più precise nella costruzione del programma e nel monitoraggio dei progressi.",
items: [
"Composizione corporea di precisione (massa grassa, magra, metabolismo basale)",
"Scansione posturale 3D e analisi di simmetria",
"Spirometria e parametri cardiovascolari",
"Analisi HRV tramite sensori dedicati",
"Report immediato, confrontabile nel tempo",
],
},
}, },
{ {
slug: "rehab", // NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
title: "Rehab", slug: "osteopatia",
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche", title: "Osteopatia",
excerpt: "Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno all’attività fisica in modo sicuro, progressivo e personalizzato.", subtitle: "Approccio manuale globale non invasivo",
longDescription: "Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno all’attività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.", excerpt: "L’osteopatia è una delle colonne portanti dell’approccio integrato di InsanityLab. Non è un servizio aggiunto: è parte della visione.",
features: ["Recupero post-infortunio", "Patologie muscolo-scheletriche", "Supervisione di chinesiologi e osteopati", "Progressione sicura e personalizzata"], longDescription: "L’osteopatia è indicata sia in modo preventivo, per chi vuole mantenere il proprio equilibrio corporeo, sia in modo curativo, per chi sta affrontando un problema specifico. È particolarmente utile in sinergia con l’allenamento, perché permette di lavorare su compensi e rigidità che altrimenti limiterebbero i progressi. Nel nostro approccio rientrano anche l’osteopatia neonatale e pediatrica.",
features: [],
faq: [ faq: [
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con l’area osteopatica." }, { q: "L’osteopatia è preventiva o curativa?", a: "Entrambe: è utile sia per mantenere l’equilibrio corporeo sia per affrontare un problema specifico, in sinergia con l’allenamento." },
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." }, { q: "Trattate anche neonati e bambini?", a: "Sì, il nostro approccio comprende anche l’osteopatia neonatale e pediatrica." },
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." }, { q: "Serve una prescrizione medica?", a: "Non è necessaria; se disponibile, eventuale documentazione clinica è utile alla prima valutazione." },
], ],
hasPricing: false, hasPricing: false,
}, },
@@ -97,6 +151,52 @@ export const services: Service[] = [
], ],
hasPricing: false, hasPricing: false,
}, },
{
// Approfondimento del riquadro Training "Rehab" (fuori dalla griglia /services).
slug: "rehab",
title: "Rehab",
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
excerpt: "Accompagniamo il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena.",
longDescription: "Il Rehab InsanityLab significa accompagnare il corpo nel recupero della sua funzionalità attraverso il movimento guidato. Non è fisioterapia in senso clinico: è il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento. Il servizio lavora con un ampio spettro di condizioni e ogni situazione viene valutata singolarmente con metodologie evidence based.",
features: [],
faq: [
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con medici specialisti, fisioterapisti e osteopati." },
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
],
hasPricing: false,
grid: false,
},
{
// Approfondimento del riquadro Training "Holistic Class" (ex Pilates Flow / Performance Class).
slug: "holistic-class",
title: "Holistic Class",
subtitle: "Classi fino a 8 persone con trainer certificato",
excerpt: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti.",
longDescription: "La Holistic Class lavora su mobilità articolare, respirazione ed equilibrio. Puoi seguirla da sola o integrarla nei tuoi allenamenti; è adatta a tutti i livelli e ogni sessione dura 40 minuti. Comprende lavoro di Pilates Flow, Mobility e Yoga.",
features: ["Pilates Flow", "Mobility", "Yoga"],
faq: [
{ q: "A chi è adatta la Holistic Class?", a: "A chiunque voglia migliorare mobilità, respirazione ed equilibrio, a ogni livello di partenza." },
{ q: "Posso integrarla con altri allenamenti?", a: "Sì: funziona sia come percorso a sé sia come complemento al tuo programma di allenamento." },
],
hasPricing: false,
grid: false,
},
{
// Approfondimento del riquadro Training "Reformer Class" (nuova).
slug: "reformer-class",
title: "Reformer Class",
subtitle: "Classi fino a 5 persone con macchinari reformer",
excerpt: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti.",
longDescription: "La Reformer Class utilizza i macchinari reformer per il pilates in classi fino a 5 persone. Il lavoro è dedicato a core, postura e mobilità, a basso impatto sulle articolazioni e adatto a tutti i livelli. Ogni lezione dura 40 minuti.",
features: ["Macchinari reformer", "Core, postura e mobilità", "Lavoro a basso impatto sulle articolazioni", "Classi fino a 5 persone", "Lezioni da 40 minuti"],
faq: [
{ q: "Serve esperienza con il reformer?", a: "No: le progressioni sono graduali e seguite da trainer certificati, adatte anche a chi inizia." },
{ q: "Quante persone per classe?", a: "Massimo 5, per garantire attenzione e correzione costante." },
],
hasPricing: false,
grid: false,
},
]; ];
export function getService(slug: string): Service | undefined { export function getService(slug: string): Service | undefined {
+17 -13
View File
@@ -2,33 +2,37 @@ export const site = {
name: "InsanityLab", name: "InsanityLab",
phone: "351 7535977", phone: "351 7535977",
email: "info@insanitylab.it", email: "info@insanitylab.it",
address: "Via Leandro Alberti, 76", // Due ingressi dello stesso stabile. address = testo mostrato; mapQuery = punto geolocalizzabile per Google Maps.
address: "Via Leandro Alberti 76 e Via De Mattiolo 5",
cityLine: "40139 Bologna", cityLine: "40139 Bologna",
hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 19.00 · Dom chiuso"], mapQuery: "IN-SANITY LAB, Via Leandro Alberti 76, 40139 Bologna",
hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 17.00 · Dom chiuso"],
socials: [ socials: [
{ label: "Instagram", url: "https://www.instagram.com/insanitylab" }, { label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
{ label: "Facebook", url: "https://www.facebook.com/insanitylab" }, { label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
{ label: "LinkedIn", url: "https://www.linkedin.com/company/insanitylab" },
], ],
navigation: [ navigation: [
{ label: "Insanitylab", href: "/about" },
{ label: "Training", href: "/training" }, { label: "Training", href: "/training" },
{ label: "Servizi", href: "/services" }, { label: "Servizi", href: "/services" },
{ label: "About us", href: "/about" },
{ label: "Blog & Edugo", href: "/blog" }, { label: "Blog & Edugo", href: "/blog" },
{ label: "Promo ed eventi", href: "/promo-eventi" },
{ label: "Contatti", href: "/contact" }, { label: "Contatti", href: "/contact" },
], ],
footerTraining: [ footerTraining: [
{ label: "One to one", href: "/training#one-to-one" }, { label: "One to one", href: "/training#one-to-one" },
{ label: "Small groups", href: "/training#small-groups" }, { label: "Personalized Training Group", href: "/training#small-groups" },
{ label: "Performance class", href: "/training#performance-class" }, { label: "Rehab", href: "/training#rehab" },
{ label: "Fit remote", href: "/training#fit-remote" }, { label: "Holistic class", href: "/training#holistic-class" },
{ label: "Reformer class", href: "/training#reformer-class" },
{ label: "Coaching", href: "/training#coaching" },
], ],
footerServices: [ footerServices: [
{ label: "Personal Training", href: "/services/personal-training" }, { label: "Personal Training", href: "/training/one-to-one" },
{ label: "Performance Class", href: "/services/performance-class" }, { label: "Class", href: "/training/class" },
{ label: "Coaching", href: "/services/coaching" }, { label: "Coaching", href: "/services/coaching" },
{ label: "Rehab", href: "/services/rehab" }, { label: "Check-up", href: "/services/profilazione" },
{ label: "Pilates Flow", href: "/services/pilates-flow" }, { label: "Osteopatia", href: "/services/osteopatia" },
{ label: "Nutrizione", href: "/services/nutrition" }, { label: "Nutrizione", href: "/services/nutrition" },
], ],
}; };
+1 -3
View File
@@ -11,8 +11,6 @@ export const team: Member[] = [
bio: "Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." }, bio: "Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." },
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, imageAbout: "team-eleonora-dark", image: "team-eleonora", { name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, imageAbout: "team-eleonora-dark", image: "team-eleonora",
bio: "Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." }, bio: "Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
{ name: "Antonello D’Alessandro", shortName: "Antonello", role: "Trainer – Exercise & Health", founder: false, imageAbout: "team-antonello-dark", image: "team-antonello",
bio: "Trainer – Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia." },
{ name: "Alice Roda", shortName: "Alice", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-alice-dark", { name: "Alice Roda", shortName: "Alice", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-alice-dark",
bio: "Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso." }, bio: "Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso." },
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-davide-dark", { name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-davide-dark",
@@ -21,4 +19,4 @@ export const team: Member[] = [
bio: "Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo." }, bio: "Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo." },
]; ];
export const homeTeam = ["Donata", "Nicola", "Eleonora", "Antonello"]; export const homeTeam = ["Donata", "Nicola", "Eleonora"];
+33 -4
View File
@@ -1,6 +1,35 @@
export const trainings = [ export const trainings = [
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Contattaci", href: "/contact" } }, { id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } },
{ id: "small-groups", title: "Small Groups", caption: "Mini gruppi (max 3 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Contattaci", href: "/contact" } }, { id: "small-groups", title: "Personalized Training Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } },
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/services/performance-class" } }, { id: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } },
{ id: "fit-remote", title: "Fit Remote", caption: "Piano di allenamento su misura, ovunque ti alleni.", image: "training-fit-remote", description: "Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.", cta: { label: "Contattaci", href: "/contact" } }, { id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } },
{ id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } },
{ id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } },
] as const; ] as const;
/**
* Le viste di /training: la pagina mostra tutti i training, oppure il solo sottoinsieme
* chiesto dal cliente (documento MODIFICHE_PER_SITO.pdf del 17/08/2026).
*
* Da /services la card «One to One» porta a One to One / Personalized Training Group / Rehab,
* la card «Class» a Holistic Class / Reformer Class: chi clicca cerca quella famiglia di
* servizi, e vedersi comparire l'elenco intero costringe a cercare di nuovo.
*/
export const VISTE_TRAINING: Record<string, { titolo: string; blocchi: readonly string[] }> = {
'one-to-one': { titolo: 'Allenamento seguito', blocchi: ['one-to-one', 'small-groups', 'rehab'] },
class: { titolo: 'Class', blocchi: ['holistic-class', 'reformer-class'] },
};
/**
* I training da mostrare per una vista. `undefined` = pagina intera.
*
* Una vista sconosciuta restituisce `null`, e la pagina risponde 404: mostrare l'elenco
* completo al posto di un filtro sbagliato farebbe passare per buono un indirizzo rotto —
* il link continuerebbe a "funzionare" mentre porta altrove.
*/
export function trainingDaMostrare(vista?: string): typeof trainings[number][] | null {
if (!vista) return [...trainings];
const v = VISTE_TRAINING[vista];
if (!v) return null;
return v.blocchi.map((id) => trainings.find((t) => t.id === id)!).filter(Boolean);
}
+175 -25
View File
@@ -1,7 +1,28 @@
--- ---
// Layout dell'area riservata. Usa i token e i font del sito, come il Campus e Stress Index:
// il pannello non è più un foglio di stile a sé, così chi passa dal sito all'amministrazione
// resta nello stesso mondo visivo.
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
interface Props { title: string } interface Props { title: string }
const { title } = Astro.props; const { title } = Astro.props;
const user = Astro.locals.user; const user = Astro.locals.user;
const path = Astro.url.pathname;
const isManager = user?.role === 'superuser' || user?.role === 'admin';
// Voci del menu: ognuna compare solo a chi può davvero aprirla.
const VOCI = [
{ href: '/admin', label: 'Articoli', visibile: true, exact: true },
{ href: '/admin/content', label: 'Contenuti', visibile: isManager },
{ href: '/admin/users', label: 'Utenti', visibile: user?.role === 'admin' },
];
const attiva = (v: { href: string; exact?: boolean }) =>
v.exact ? path === v.href : path.startsWith(v.href);
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -10,31 +31,160 @@ const user = Astro.locals.user;
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Admin InsanityLab</title> <title>{title} — Admin InsanityLab</title>
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<style is:global> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
.abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
.abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
.awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
.abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
.abtn--danger { background: #a33; }
.afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
table { width: 100%; border-collapse: collapse; background: #fff; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
.pill--draft { background: #f3d9a4; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
</style>
</head> </head>
<body> <body class="ad">
<div class="abar"> <header class="abar">
<strong>InsanityLab · Admin</strong> <a class="abar__brand" href="/admin">
<span> <span class="abar__eyebrow">InsanityLab</span>
{user && user.role === 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin/content">Contenuti</a> <a href="/admin/logout">Esci</a></>} Area riservata
{user && user.role !== 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/content">Contenuti</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>} </a>
</span>
</div> {user && (
<div class="awrap"><slot /></div> <nav class="abar__nav" aria-label="pannello">
{VOCI.filter((v) => v.visibile).map((v) => (
<a href={v.href} class:list={['abar__link', { 'is-active': attiva(v) }]}>{v.label}</a>
))}
<a class="abar__link" href="/" target="_blank" rel="noopener">Vai a sito</a>
<a class="abar__link abar__link--out" href="/admin/logout">Esci</a>
</nav>
)}
{user && <p class="abar__user">{user.username}</p>}
</header>
<main class="awrap"><slot /></main>
<style is:global>
body.ad { background: var(--c-bg-alt); }
/* --- barra --- */
.abar {
display: flex; align-items: center; gap: 28px; flex-wrap: wrap;
background: var(--c-dark); color: #cfc6bd; padding: 16px 32px;
}
.abar__brand {
font-family: var(--font-heading); font-size: 1rem; font-weight: 600; letter-spacing: .12em;
text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.15;
}
.abar__eyebrow { display: block; font-size: .62rem; letter-spacing: .2em; color: var(--c-accent); }
.abar__nav { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
.abar__link {
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; color: #b6ada3; text-decoration: none; padding: 4px 0;
border-bottom: 1px solid transparent;
}
.abar__link:hover { color: #fff; }
.abar__link.is-active { color: #fff; border-bottom-color: var(--c-accent); }
.abar__link--out { color: var(--c-accent); }
.abar__user {
margin: 0 0 0 auto; font-size: .74rem; letter-spacing: .12em; text-transform: uppercase; color: #8d8379;
}
/* --- contenuto --- */
.awrap { max-width: 1080px; margin: 40px auto 80px; padding: 0 24px; }
.awrap h1 {
font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .6em;
}
.awrap h2 { font-size: 1.15rem; text-transform: none; letter-spacing: .02em; }
/* --- comandi --- */
.abtn {
display: inline-block; background: var(--c-dark); color: #fff; border: 1px solid var(--c-dark);
padding: 10px 20px; cursor: pointer; text-decoration: none; font-family: var(--font-heading);
font-size: .7rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
}
.abtn:hover { background: var(--c-dark-2); border-color: var(--c-dark-2); }
.abtn--danger { background: none; color: #a4544a; border-color: #ddcfc9; }
.abtn--danger:hover { background: #a4544a; border-color: #a4544a; color: #fff; }
.afield {
width: 100%; padding: 11px 13px; margin-bottom: 14px; border: 1px solid #ddd6cb;
box-sizing: border-box; font: inherit; font-size: .92rem; background: #fff; border-radius: 0;
}
.afield:focus { outline: 2px solid var(--c-accent); outline-offset: -2px; }
/* --- tabelle --- */
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e4dfd7; }
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid #efeae2; font-size: .9rem; }
th {
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: var(--c-text-light); background: #faf8f5;
}
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #faf8f5; }
td a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
td a:hover { text-decoration: underline; }
.pill {
display: inline-block; font-family: var(--font-heading); font-size: .64rem; font-weight: 600;
letter-spacing: .1em; text-transform: uppercase; padding: 3px 10px;
background: rgba(111,143,106,.16); color: #5f7c5a;
}
.pill--draft { background: rgba(192,138,62,.18); color: #96682a; }
.atoolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.amuted { color: var(--c-text-light); font-size: .9rem; }
.aform { max-width: 420px; background: #fff; border: 1px solid #e4dfd7; padding: 26px 24px; }
.alabel {
display: block; margin-bottom: 6px; font-family: var(--font-heading); font-size: .68rem;
font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light);
}
.ahint { margin: -8px 0 16px; font-size: .78rem; color: var(--c-text-light); }
.aactions { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.abtn--ghost { background: none; color: var(--c-heading); border-color: #ddd6cb; }
.abtn--ghost:hover { background: none; border-color: var(--c-accent); color: var(--c-accent-dark); }
/* Ruolo: una pillola che resta un <select>, con la freccia disegnata a parte perché
quella di sistema non si può vestire. */
.arole {
appearance: none; -webkit-appearance: none; border: 1px solid #e0d9cd; background-color: #faf8f5;
padding: 6px 30px 6px 12px; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); cursor: pointer;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%238a8a8a' stroke-width='1.6'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 11px center; background-size: 9px;
}
.arole:hover { border-color: var(--c-accent); }
.arole:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
.arole[data-role='admin'] { background-color: #efe7dc; border-color: #d9c7ab; color: #6b5334; }
.arole[data-role='superuser'] { background-color: #ece7dc; border-color: #d8cfbe; color: #6f6353; }
.arole[data-role='piattaforme'] { background-color: #e6ede4; border-color: #cbdcc6; color: #4d6a48; }
/* Riquadro della password appena generata */
.apw { background: #fff; border: 1px solid var(--c-accent); padding: 18px 20px; margin: 0 0 20px; }
.apw__label { margin: 0 0 10px; font-size: .85rem; color: var(--c-text-light); }
.apw__label strong { color: var(--c-heading); }
.apw__row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.apw__row code {
background: var(--c-bg-alt); border: 1px solid #e4dfd7; padding: 9px 14px;
font-size: 1rem; letter-spacing: .06em; color: var(--c-heading);
}
.apw__hint { margin: 10px 0 0; font-size: .78rem; color: var(--c-text-light); }
.apw__input { width: auto; min-width: 260px; margin-bottom: 0; }
/* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */
a.aicon { text-decoration: none; }
.aicon {
display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px;
padding: 0; background: none; border: 1px solid #ddd6cb; cursor: pointer; color: var(--c-text);
}
.aicon + .aicon { margin-left: 6px; }
.aicon svg { width: 17px; height: 17px; fill: currentColor; }
.aicon:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
.aicon--danger:hover { border-color: #a4544a; background: #a4544a; color: #fff; }
td.uactions, th.uactions { text-align: right; white-space: nowrap; }
.form-msg { font-size: .88rem; margin-top: 8px; }
.form-msg--ok { color: #5f7c5a; }
.form-msg--err { color: #a4544a; }
@media (max-width: 720px) {
.abar { padding: 14px 20px; gap: 14px; }
.abar__nav { gap: 16px; width: 100%; }
.abar__user { margin-left: 0; }
.awrap { margin-top: 26px; padding: 0 20px; }
}
</style>
</body> </body>
</html> </html>
+202 -26
View File
@@ -14,16 +14,22 @@ import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
interface Props { title: string; description?: string } interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props; const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
// Overlay annotate: badge coi tag contenuto, solo per utenti loggati con ?annotate=1. const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
let annotate = false; const user = tok ? getSessionUser(getDb(), tok) : null;
if (Astro.url.searchParams.get('annotate') === '1') { const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
const tok = Astro.cookies.get(SESSION_COOKIE)?.value; const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
annotate = Boolean(tok && getSessionUser(getDb(), tok)); const showTags = canTags && wantTags;
}
// Google Tag Manager: l'ID container arriva da GTM_ID (.env), letto a runtime via process.env
// perché in SSR le PUBLIC_* di Vite verrebbero inlinate a build-time. Se vuoto GTM non carica.
const gtmId = process.env.GTM_ID;
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
<head> <head>
{gtmId && (
<script is:inline set:html={`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtmId}');`} />
)}
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title> <title>{title} — InsanityLab</title>
@@ -37,7 +43,10 @@ if (Astro.url.searchParams.get('annotate') === '1') {
<script is:inline>document.documentElement.classList.add('js');</script> <script is:inline>document.documentElement.classList.add('js');</script>
</head> </head>
<body> <body>
<Header /> {gtmId && (
<noscript set:html={`<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
)}
<Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main> <main><slot /></main>
<Footer /> <Footer />
<script> <script>
@@ -74,30 +83,197 @@ if (Astro.url.searchParams.get('annotate') === '1') {
update(); update();
} }
</script> </script>
{annotate && ( {showTags && (
<style is:global> <style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; } [data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; background: #3a2929; color: #fff; .tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
font: 600 10px/1.6 monospace; padding: 0 6px; text-decoration: none; opacity: .85; } background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; border-radius: 3px; }
.tag-badge:hover { opacity: 1; background: #9c8b70; } .tag-badge:hover { opacity: 1; }
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; border-radius: 2px; }
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(28,20,16,.55); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center; padding: 20px; }
.tag-modal[hidden] { display: none; }
.tag-modal__box { background: #fdfbf7; color: #2b2320; width: min(600px, 94vw); max-height: 88vh; overflow: auto;
border-radius: 14px; box-shadow: 0 24px 60px rgba(28,20,16,.35); font-family: system-ui, sans-serif; }
.tag-modal__head { padding: 20px 24px 16px; border-bottom: 1px solid #ece4d8; }
.tag-modal__head h3 { margin: 0; font: 600 1.05rem/1.3 system-ui, sans-serif; letter-spacing: .01em; }
.tag-modal__head .tm-tagchip { display: inline-block; margin-top: 8px; background: #efe8dc; color: #6b5b48;
font: 500 11px/1.6 ui-monospace, monospace; padding: 2px 9px; border-radius: 999px; word-break: break-all; }
.tag-modal__head .guid { font: 10px/1.5 ui-monospace, monospace; color: #a99f90; margin: 6px 0 0; }
.tag-modal__body { padding: 20px 24px; }
.tag-modal__box textarea { width: 100%; min-height: 150px; font: inherit; font-size: .95rem; line-height: 1.5;
padding: 12px 14px; box-sizing: border-box; border: 1px solid #dcd2c4; border-radius: 10px; background: #fff;
resize: vertical; color: #2b2320; }
.tag-modal__box textarea:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-styles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px 14px; margin-top: 18px; }
.tm-field { display: flex; flex-direction: column; gap: 5px; }
.tm-field__label { font: 600 11px/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: .08em; color: #8a7c68; }
.tm-field select { appearance: none; width: 100%; padding: 9px 30px 9px 12px; border: 1px solid #dcd2c4; border-radius: 9px;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%238a7c68' stroke-width='1.6'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E") no-repeat right 11px center;
font: inherit; font-size: .9rem; color: #2b2320; cursor: pointer; }
.tm-field select:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-field select.is-set { border-color: #9c8b70; background-color: #f6f1e8; font-weight: 600; }
.tag-modal__box input[type=file] { display: block; width: 100%; font: inherit; font-size: .9rem; padding: 10px;
border: 1px dashed #cbbfad; border-radius: 10px; background: #fff; box-sizing: border-box; }
.tm-del { margin-top: 12px; padding: 8px 14px; border: 0; border-radius: 8px; background: #b4443a; color: #fff; cursor: pointer; font: 600 .85rem system-ui, sans-serif; }
.tag-modal__msg { color: #b4443a; margin: 12px 24px 0; min-height: 18px; font-size: .88rem; }
.tag-modal__row { display: flex; gap: 10px; justify-content: flex-end; padding: 16px 24px 22px; }
.tag-modal__row button { padding: 10px 20px; border: 0; border-radius: 9px; cursor: pointer; font: 600 .9rem system-ui, sans-serif; }
.tag-modal__save { background: #3a2d26; color: #fff; }
.tag-modal__save:hover { background: #2b211b; }
.tag-modal__cancel { background: #ece4d8; color: #5b4f42; }
.tag-modal__cancel:hover { background: #e0d6c6; }
</style> </style>
<div class="tag-modal" id="tag-modal" hidden>
<div class="tag-modal__box">
<div class="tag-modal__head">
<h3>Modifica contenuto</h3>
<span class="tm-tagchip" id="tm-tag"></span>
<p class="guid" id="tm-guid"></p>
</div>
<div class="tag-modal__body" id="tm-body"></div>
<div class="tag-modal__msg" id="tm-msg"></div>
<div class="tag-modal__row">
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
</div>
</div>
</div>
<script is:inline> <script is:inline>
document.querySelectorAll('[data-tag]').forEach((el) => { (() => {
const a = document.createElement('a'); const STYLE_GROUPS = { size: ['s','m','l','xl','xxl'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right','justify'] };
a.className = 'tag-badge'; const GROUP_LABELS = { size: 'Dimensione', color: 'Colore', weight: 'Peso', style: 'Stile', align: 'Allineamento' };
a.textContent = el.dataset.tag; const VALUE_LABELS = {
a.href = '/admin/content#' + encodeURIComponent(el.dataset.tag); size: { s: 'S', m: 'M', l: 'L', xl: 'XL', xxl: 'XXL' },
a.target = '_blank'; weight: { normal: 'Normale', bold: 'Grassetto' },
document.body.appendChild(a); style: { normal: 'Normale', italic: 'Corsivo' },
const place = () => { align: { left: 'Sinistra', center: 'Centro', right: 'Destra', justify: 'Giustificato' },
const r = el.getBoundingClientRect(); color: { accent: 'Accento', 'accent-dark': 'Accento scuro', dark: 'Scuro', heading: 'Titolo', text: 'Testo', 'text-light': 'Testo chiaro', white: 'Bianco' },
a.style.top = window.scrollY + r.top - 14 + 'px';
a.style.left = window.scrollX + r.left + 'px';
}; };
place(); const valLabel = (g, v) => (VALUE_LABELS[g] && VALUE_LABELS[g][v]) || v;
addEventListener('scroll', place, { passive: true }); const modal = document.getElementById('tag-modal');
addEventListener('resize', place); const tmTag = document.getElementById('tm-tag');
}); const tmGuid = document.getElementById('tm-guid');
const tmBody = document.getElementById('tm-body');
const tmMsg = document.getElementById('tm-msg');
const tmSave = document.getElementById('tm-save');
let current = null; // { tag, type, el }
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
document.getElementById('tm-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
async function openModal(tag, el) {
tmMsg.textContent = '';
let data;
try {
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
data = await res.json();
} catch { alert('Errore di rete.'); return; }
current = { tag: data.tag, type: data.type, el };
tmTag.textContent = data.tag;
tmGuid.textContent = data.guid || '';
tmBody.innerHTML = '';
if (data.type === 'image') {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
tmBody.appendChild(inp);
const del = document.createElement('button');
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del'; del.className = 'tm-del';
tmBody.appendChild(del);
del.addEventListener('click', async () => {
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
});
} else {
const ta = document.createElement('textarea');
ta.id = 'tm-value'; ta.value = data.value;
tmBody.appendChild(ta);
const styles = data.styles || {};
const groups = (data.styleOptions || []).filter((g) => STYLE_GROUPS[g]);
if (groups.length) {
const grid = document.createElement('div');
grid.className = 'tm-styles';
groups.forEach((group) => {
const field = document.createElement('label');
field.className = 'tm-field';
const cap = document.createElement('span');
cap.className = 'tm-field__label';
cap.textContent = GROUP_LABELS[group] || group;
const sel = document.createElement('select');
sel.dataset.group = group;
sel.className = 'tm-style';
const none = document.createElement('option');
none.value = ''; none.textContent = '— default —';
sel.appendChild(none);
STYLE_GROUPS[group].forEach((v) => {
const o = document.createElement('option');
o.value = v; o.textContent = valLabel(group, v);
if (styles[group] === v) o.selected = true;
sel.appendChild(o);
});
// Evidenzia la combo se un valore è impostato.
if (styles[group]) sel.classList.add('is-set');
sel.addEventListener('change', () => sel.classList.toggle('is-set', !!sel.value));
field.appendChild(cap); field.appendChild(sel);
grid.appendChild(field);
});
tmBody.appendChild(grid);
}
}
modal.hidden = false;
}
tmSave.addEventListener('click', async () => {
if (!current) return;
tmMsg.textContent = '';
if (current.type === 'image') {
const file = document.getElementById('tm-file').files[0];
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
const fd = new FormData(); fd.append('file', file);
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
return;
}
const value = document.getElementById('tm-value').value;
const styles = {};
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
const body = { value };
if (Object.keys(styles).length) body.styles = styles;
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
const saved = await r.json();
// Aggiorna in-place il contenuto nel DOM.
if (current.type === 'html') current.el.innerHTML = saved.value;
else current.el.textContent = saved.value;
closeModal();
});
document.querySelectorAll('[data-tag]').forEach((el) => {
const tag = el.dataset.tag;
const guid = el.dataset.guid || '';
const badge = document.createElement('span');
badge.className = 'tag-badge';
const label = document.createElement('span');
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
const edit = document.createElement('button');
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
edit.addEventListener('click', () => openModal(tag, el));
badge.appendChild(label); badge.appendChild(edit);
document.body.appendChild(badge);
const place = () => {
const r = el.getBoundingClientRect();
badge.style.top = window.scrollY + r.top - 16 + 'px';
badge.style.left = window.scrollX + r.left + 'px';
};
place();
addEventListener('scroll', place, { passive: true });
addEventListener('resize', place);
});
})();
</script> </script>
)} )}
</body> </body>
+85 -4
View File
@@ -5,7 +5,16 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session'; export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
export type Role = 'admin' | 'editor'; // Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole,
// così aggiungere un ruolo in futuro è una modifica sola. Prima erano quattro punti
// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs -
// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta
// duplicato: è JavaScript, non può importare da qui, vedi il commento lì).
// ⚠️ Solo i ruoli redazionali: `piattaforme`, `cliente` e `trainer` sono passati
// all'app delle aree riservate (repo InsanityLab-platforms) insieme alle loro pagine. Un utente
// del sito non è un utente di quella, e viceversa: due anagrafiche, due database.
export const ROLES = ['user', 'superuser', 'admin'] as const;
export type Role = typeof ROLES[number];
export function hashPassword(plain: string): string { export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12); return bcrypt.hashSync(plain, 12);
@@ -49,10 +58,82 @@ export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token); db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
} }
// Percorsi admin consentiti anche al ruolo editor. // Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/]; // che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e
// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto:
// qui arrivano solo utenti già autenticati su una di quelle rotte.
const RULES: [RegExp, Role[]][] = [
[/^\/admin\/users(\/|$)/, ['admin']],
[/^\/api\/admin\/users(\/|$)/, ['admin']],
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
];
export function canAccessAdminPath(role: string, pathname: string): boolean { export function canAccessAdminPath(role: string, pathname: string): boolean {
if (role === 'admin') return true; if (role === 'admin') return true;
return EDITOR_ALLOWED.some((re) => re.test(pathname)); for (const [re, roles] of RULES) {
if (re.test(pathname)) return roles.includes(role as Role);
}
// ⚠️ Un ruolo che qui non esiste più (piattaforme, cliente, trainer: rimasti nel database
// di produzione da prima della separazione) non deve cadere nel default permissivo qui
// sotto, che è pensato per gli utenti redazionali. Chi non è un ruolo di questo sito non
// passa: la sua casa è apps.insanitylab.it.
if (!isRole(role)) return pathname === '/admin/logout';
return true; // blog, upload, logout, showtags: tutti i loggati
}
export function getUsernameById(db: Database.Database, id: number): string | null {
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
return row?.username ?? null;
}
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
{ id: number; username: string; role: string }[];
}
export function countAdmins(db: Database.Database): number {
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
}
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
{ id: number; username: string; role: string } | undefined;
return row ?? null;
}
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
}
export function deleteUser(db: Database.Database, id: number): void {
db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
export function randomPassword(): string {
return randomBytes(9).toString('base64url'); // ~12 caratteri
}
export function isRole(v: unknown): v is Role {
return (ROLES as readonly unknown[]).includes(v);
}
export function landingFor(role: string): string {
if (role === 'superuser') return '/admin/content';
return '/admin';
}
export function isProtectedPath(pathname: string): boolean {
// ⚠️ Restano solo il pannello e le sue API: /campus, /piattaforme e /longevity sono
// passate ad apps.insanitylab.it il 03/09/2026. Se un giorno ricompaiono qui degli
// URL con quei prefissi, non sono protetti da niente — non esistono proprio.
return (
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin')
);
} }
+187
View File
@@ -0,0 +1,187 @@
import PDFDocument from 'pdfkit';
import { GIORNI, type AvvisoCalendario, type Calendario, type Disciplina, type Giorno } from '../data/calendari';
// PDF del calendario, disegnato dal dato con PDFKit.
//
// Non è la stampa del browser: è un file vero, che si scarica da un link e si può girare su
// WhatsApp. Serve un documento, non uno screenshot della pagina — chi lo riceve non ha il
// sito aperto davanti.
//
// Niente browser headless di mezzo: Chromium in un container da 2 vCPU costa centinaia di
// megabyte e un processo per ogni richiesta, per una griglia di sette colonne.
/** Colori delle discipline, con la barra a sinistra più scura del fondo (come in pagina). */
const COLORI: Record<Disciplina, { fondo: string; barra: string }> = {
REFORMER: { fondo: '#ece5d7', barra: '#9c8b70' },
PILATES: { fondo: '#f6efe0', barra: '#cbb98f' },
YOGA: { fondo: '#e3eadf', barra: '#8ba57f' },
MOBILITY: { fondo: '#f0e3e3', barra: '#b78e8e' },
};
const C_TESTO = '#2b2b2b';
const C_TENUE = '#8a8a8a';
const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni uguali in fasce
// consecutive devono restare due, non sembrare un blocco unico
const C_ACCENTO = '#9c8b70';
const C_ALT = '#f5f3f0';
/** Un solo orario: il nome porta il suo id. Più orari insieme: il nome è generico. */
export const nomeFilePdf = (cals: Calendario[]): string =>
cals.length === 1 ? `insanitylab-calendario-${cals[0].id}.pdf` : 'insanitylab-calendario-class.pdf';
/**
* Genera il PDF e restituisce il file completo: **un orario per pagina**.
*
* Ogni griglia sta in una pagina intera perché spezzarla renderebbe illeggibile proprio ciò
* che si va a cercare — l'incrocio fra un giorno e un'ora; e due orari sullo stesso foglio si
* confonderebbero fra loro. Le giornate fuori griglia si ripetono su ogni pagina: un foglio
* stampato da solo deve bastare a sé stesso.
*/
export function generaCalendariPdf(cals: Calendario[], avvisi: AvvisoCalendario[] = []): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: 32,
info: {
// Il titolo è quello che compare nella scheda del lettore PDF e nel nome suggerito dai
// telefoni: con un orario solo dice quale, con più orari dice il periodo coperto.
Title: cals.length === 1
? `InsanityLab — ${cals[0].sala}, ${cals[0].titolo}`
: `InsanityLab — Calendario delle class (${cals.map((c) => c.titolo).join('; ')})`,
Author: 'InsanityLab',
Subject: 'Calendario delle class',
},
});
const pezzi: Buffer[] = [];
const finito = new Promise<Buffer>((risolvi, rifiuta) => {
doc.on('data', (p: Buffer) => pezzi.push(p));
doc.on('end', () => risolvi(Buffer.concat(pezzi)));
doc.on('error', rifiuta);
});
cals.forEach((cal, indice) => {
if (indice > 0) doc.addPage();
disegnaPagina(doc, cal, avvisi);
});
doc.end();
return finito;
}
/** Una pagina: testata, griglia, legenda, giornate fuori griglia, piè di pagina. */
function disegnaPagina(doc: PDFKit.PDFDocument, cal: Calendario, avvisi: AvvisoCalendario[]): void {
const x0 = doc.page.margins.left;
const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// --- Testata ---
doc.font('Helvetica-Bold').fontSize(16).fillColor(C_TESTO)
.text('INSANITYLAB', x0, doc.page.margins.top);
doc.font('Helvetica').fontSize(8).fillColor(C_ACCENTO)
.text(cal.sala.toUpperCase(), x0, doc.y + 2, { characterSpacing: 1.6 });
doc.font('Helvetica-Bold').fontSize(12).fillColor(C_TESTO)
.text(cal.titolo, x0, doc.y + 4);
// --- Griglia ---
const larghezzaOra = 52;
const larghezzaGiorno = (larghezzaUtile - larghezzaOra) / GIORNI.length;
const conNota = GIORNI.some((g) => cal.noteGiorno?.[g]);
const altezzaTestata = conNota ? 26 : 18;
let y = doc.y + 12;
const xDi = (i: number) => x0 + larghezzaOra + i * larghezzaGiorno;
// Intestazione delle colonne
doc.rect(x0, y, larghezzaUtile, altezzaTestata).fill(C_ALT);
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_ACCENTO)
.text('ORA', x0 + 5, y + 6, { width: larghezzaOra - 10, characterSpacing: 1 });
GIORNI.forEach((g, i) => {
doc.font('Helvetica-Bold').fontSize(7).fillColor(cal.chiusi.includes(g) ? C_TENUE : C_TESTO)
.text(g, xDi(i) + 5, y + 6, { width: larghezzaGiorno - 10, characterSpacing: 1 });
const nota = cal.noteGiorno?.[g];
if (nota) {
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
.text(nota, xDi(i) + 5, y + 15, { width: larghezzaGiorno - 8 });
}
});
y += altezzaTestata;
// Righe: l'altezza si ricava dallo spazio che resta, così la griglia riempie la pagina
// qualunque sia il numero di fasce e non serve ritoccarla a mano quando ne cambiano.
// legenda + nota + piè di pagina, più una riga per ogni giornata fuori griglia: senza
// tenerne conto la griglia si allungherebbe fino a spingere gli avvisi in una seconda pagina.
const spazioSotto = 52 + avvisi.length * 11;
const altezzaRiga = Math.min(
22,
(doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length,
);
// Prima passata: riempimenti e testi.
const yPrimaRiga = y;
for (const r of cal.righe) {
if (r.clou) doc.rect(x0, y, larghezzaOra, altezzaRiga).fill('#f7f2e6');
doc.font('Helvetica-Bold').fontSize(7.5).fillColor(C_TESTO)
.text(r.ora, x0 + 5, y + altezzaRiga / 2 - 4, { width: larghezzaOra - 8 });
GIORNI.forEach((g: Giorno, i) => {
const x = xDi(i);
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
if (chiuso) {
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill('#efece7');
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
.text('CHIUSO', x + 5, y + altezzaRiga / 2 - 3, { width: larghezzaGiorno - 8, characterSpacing: 0.8 });
} else if (lezione) {
const c = COLORI[lezione];
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill(c.fondo);
doc.rect(x, y, 2.5, altezzaRiga).fill(c.barra);
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C_TESTO)
.text(lezione, x + 7, y + altezzaRiga / 2 - 3.5, { width: larghezzaGiorno - 10, characterSpacing: 0.5 });
}
});
y += altezzaRiga;
}
// Seconda passata: la griglia sopra i riempimenti. Tracciando le linee riga per riga, il
// riempimento della riga seguente copriva quella appena disegnata e due lezioni uguali una
// sotto l'altra si leggevano come un unico blocco lungo il doppio.
doc.lineWidth(0.4).strokeColor(C_BORDO);
for (let i = 0; i <= cal.righe.length; i++) {
const yy = yPrimaRiga + i * altezzaRiga;
doc.moveTo(x0, yy).lineTo(x0 + larghezzaUtile, yy).stroke();
}
for (let i = 0; i <= GIORNI.length; i++) {
const x = i === 0 ? x0 + larghezzaOra : xDi(i);
doc.moveTo(x, yPrimaRiga - altezzaTestata).lineTo(x, y).stroke();
}
doc.rect(x0, yPrimaRiga - altezzaTestata, larghezzaUtile, altezzaTestata + altezzaRiga * cal.righe.length).stroke();
// --- Legenda: solo le discipline che compaiono davvero in questa griglia ---
const presenti = (Object.keys(COLORI) as Disciplina[])
.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
let xLeg = x0;
const yLeg = y + 12;
for (const d of presenti) {
doc.rect(xLeg, yLeg, 9, 9).fill(COLORI[d].fondo);
doc.rect(xLeg, yLeg, 2.5, 9).fill(COLORI[d].barra);
doc.font('Helvetica').fontSize(7).fillColor(C_TESTO)
.text(d, xLeg + 13, yLeg + 2, { width: 70 });
xLeg += 13 + doc.widthOfString(d) + 18;
}
// --- Giornate fuori griglia (eventi, chiusure) ---
let yAvvisi = yLeg + 16;
for (const a of avvisi) {
doc.rect(x0, yAvvisi - 1, 2, 9).fill(C_ACCENTO);
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_TESTO)
.text(a.quando, x0 + 7, yAvvisi, { continued: true })
.font('Helvetica').fillColor(C_TENUE).text(` — ${a.testo}`);
yAvvisi += 11;
}
doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text(
"Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.",
x0, yAvvisi + 3, { width: larghezzaUtile },
);
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
.text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' });
}
+6 -3
View File
@@ -1,6 +1,6 @@
export interface ContactData { export interface ContactData {
firstName: string; lastName: string; phone: string; firstName: string; lastName: string; phone: string;
email: string; message: string; website: string; email: string; message: string; hpField: string;
} }
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -20,10 +20,13 @@ export function validateContact(data: unknown):
phone: str(d.phone, 40), phone: str(d.phone, 40),
email: str(d.email, 200), email: str(d.email, 200),
message: str(d.message, 5000), message: str(d.message, 5000),
website: str(d.website, 200), // Honeypot: nome neutro per non farlo riempire dall'autofill del browser.
hpField: str(d.hp_field, 200),
}; };
if (value.website) return { ok: false, error: 'Richiesta non valida.' }; if (value.hpField) return { ok: false, error: 'Richiesta non valida.' };
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' }; if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
if (!value.lastName) return { ok: false, error: 'Il cognome è obbligatorio.' };
if (!value.phone) return { ok: false, error: 'Il telefono è obbligatorio.' };
if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' }; if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' }; if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' }; if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
+11 -10
View File
@@ -1,13 +1,14 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { getDb } from './db'; import { getDb } from './db';
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed'; import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
import { stylesToClasses } from './style-presets'; import { stylesToClasses } from './style-presets';
interface Entry { value: string; type: ContentType; styles: Record<string, string> } interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
export function syncSeed(db: Database.Database): void { export function syncSeed(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
tx(); tx();
} }
@@ -16,25 +17,25 @@ export function makeContentStore(dbGetter: () => Database.Database) {
const load = (): Map<string, Entry> => { const load = (): Map<string, Entry> => {
if (cache) return cache; if (cache) return cache;
const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as
{ tag: string; type: ContentType; value: string; styles: string }[]; { tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
cache = new Map(rows.map((r) => { cache = new Map(rows.map((r) => {
let styles: Record<string, string> = {}; let styles: Record<string, string> = {};
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ } try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof styles !== 'object' || styles === null) styles = {}; if (typeof styles !== 'object' || styles === null) styles = {};
return [r.tag, { value: r.value, type: r.type, styles }]; return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
})); }));
return cache; return cache;
}; };
return { return {
resolve(tag: string): { value: string; type: ContentType; classes: string } { resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
const hit = load().get(tag); const hit = load().get(tag);
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) }; if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
const seed = seedByTag.get(tag); const seed = seedByTag.get(tag);
if (seed) return { value: seed.value, type: seed.type, classes: '' }; if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
console.warn(`[content] tag sconosciuto: ${tag}`); console.warn(`[content] tag sconosciuto: ${tag}`);
return { value: '', type: 'text', classes: '' }; return { value: '', type: 'text', classes: '', guid: '' };
}, },
invalidate(): void { cache = null; }, invalidate(): void { cache = null; },
}; };
+188 -5
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { contentSeed } from '../data/content-seed'; import { contentSeed } from '../data/content-seed';
const SCHEMA = ` const SCHEMA = `
@@ -15,7 +16,9 @@ CREATE TABLE IF NOT EXISTS posts (
draft INTEGER NOT NULL DEFAULT 1, draft INTEGER NOT NULL DEFAULT 1,
published_at TEXT, published_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Cestino: valorizzata quando l'articolo viene rimosso dal sito ma resta recuperabile.
deleted_at TEXT
); );
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -50,6 +53,20 @@ export function createDb(path?: string): Database.Database {
if (!userCols.some((c) => c.name === 'role')) { if (!userCols.some((c) => c.name === 'role')) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`); db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
} }
// Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
// GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
if (!contentCols.some((c) => c.name === 'guid')) {
db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
}
// Autore articoli: colonna nullable (post storici restano senza autore).
const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
if (!postCols.some((c) => c.name === 'author_id')) {
db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
}
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti // Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici. // preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE // UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
@@ -76,14 +93,14 @@ export function createDb(path?: string): Database.Database {
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello): // le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
// il nuovo valore è quello del seed corrente per lo stesso tag. // il nuovo valore è quello del seed corrente per lo stesso tag.
const OLD_SECTION_DEFAULTS: [string, string][] = [ const OLD_SECTION_DEFAULTS: [string, string][] = [
['global.nav.3.label', 'Programmi'], // La voce "Servizi" (ex "Programmi") è la 2ª del menu dopo il riordino (commit 5ff4771).
['global.nav.2.label', 'Programmi'],
['footer.col.services-title', 'Programmi'], ['footer.col.services-title', 'Programmi'],
['home.services.title', 'Programmi'], ['home.services.title', 'Programmi'],
['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'], ['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'],
['services.meta.title', 'Programmi'], ['services.meta.title', 'Programmi'],
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'], ['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
['services.hero.title', 'Programmi'], ['services.hero.title', 'Programmi'],
['services.intro.title', 'Programmi'],
["services.intro.body", "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."], ["services.intro.body", "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."],
['service.side.others-label', 'Altri programmi'], ['service.side.others-label', 'Altri programmi'],
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'], ['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
@@ -96,9 +113,175 @@ export function createDb(path?: string): Database.Database {
} }
}); });
renameTx(); renameTx();
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); // Modifiche 03: "Personal Training" → "One to One" (nuovo slug one-to-one). Rinomina i tag
// esistenti prima del seed per preservare le modifiche da pannello; il title di default viene
// aggiornato solo se non modificato. La nuova pagina "Small Group" (slug small-group) è un
// servizio nuovo e viene inserita dal seed più sotto. Idempotente.
const oneToOneTx = db.transaction(() => {
renameUpd.run('service.one-to-one.', 'service.personal-training.'.length + 1, 'service.personal-training.%');
renameDel.run('service.personal-training.%');
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal Training'").run();
});
oneToOneTx();
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
syncTx(); syncTx();
// Modifiche 02 (pagina Servizi): "Pilates Flow" → "Profilazione" e "Rehab" → "Osteopatia".
// I nuovi servizi hanno slug nuovi (tag service.profilazione.* / service.osteopatia.*) e
// vengono inseriti dal seed qui sopra. Restano da aggiornare le righe EXISTING il cui valore
// cambia: label del footer e meta description. UPDATE guardato dal vecchio valore → idempotente
// e non sovrascrive eventuali modifiche fatte dal pannello.
const SERVIZI_02: [string, string, string][] = [
['footer.services.4.label', 'Rehab', 'Profilazione'],
['footer.services.5.label', 'Pilates Flow', 'Osteopatia'],
['services.meta.description',
'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.',
'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.'],
// Pagina Training: riquadri passati a One to One, Small Group, Rehab, Holistic Class,
// Reformer Class, Coaching. Label footer + meta aggiornate (righe 5-6 sono nuovi tag).
['footer.training.2.label', 'Small groups', 'Small group'],
['footer.training.3.label', 'Performance class', 'Rehab'],
['footer.training.4.label', 'Fit remote', 'Holistic class'],
['training.meta.description',
'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.',
'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.'],
];
const upd02 = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
const upd02Tx = db.transaction(() => { for (const [tag, oldV, newV] of SERVIZI_02) upd02.run(newV, tag, oldV); });
upd02Tx();
// Metodo (home): step riscritti dal pannello in dev ma mai propagati in produzione
// (il DB non è versionato: gli edit vivono solo nel volume). Qui si allineano le righe
// di prod ancora ai vecchi default del seed, senza toccare eventuali modifiche successive.
const METODO: [string, string, string][] = [
['method.step.1.text', 'Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', 'La base di partenza per definire le priorità del percorso di ciascun cliente.'],
['method.step.2.title', 'Prima seduta operativa', 'Check-up fisico'],
['method.step.2.text', 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', 'Profilazione fisica per definire lo stato di salute iniziale del cliente.'],
['method.step.3.title', 'Plicometria', 'Insanity plan'],
['method.step.3.text', 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', 'Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.'],
['method.step.4.title', 'Analisi antropometriche', 'Monitoraggio costante'],
['method.step.4.text', 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', 'Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.'],
];
const updMetodoTx = db.transaction(() => { for (const [tag, oldV, newV] of METODO) upd02.run(newV, tag, oldV); });
updMetodoTx();
// Modifiche promo (commit 1522d97): la freccia "→" dei bottoni è passata al CSS (::after) e il
// maiuscolo a text-transform. Il seed è ora minuscolo e senza freccia. Le righe di prod ancora
// al vecchio default vanno allineate, altrimenti il CSS ::after produce una DOPPIA freccia.
// Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
const PROMO_FRECCE: [string, string, string][] = [
['promo.hero.cta', 'LE NOSTRE PROPOSTE →', 'Le nostre proposte'],
['promo.buy-label', 'ACQUISTA →', 'Acquista'],
];
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
updPromoTx();
// Listino promo agosto 2026 (finestra 1–19 agosto): nuovi prezzi comunicati dal cliente e nota
// di validità con le date. I prezzi barrati (price-old) restano invariati. UPDATE guardato dal
// vecchio valore → idempotente e non sovrascrive eventuali ritocchi fatti dal pannello.
const PROMO_AGOSTO_2026: [string, string, string][] = [
['promo.card.1.price', '163 €', '200 €'],
['promo.card.2.price', '110 €', '125 €'],
['promo.card.3.price', '100 €', '115 €'],
['promo.card.4.price', '50 €', '75 €'],
['promo.validity-note',
"Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026",
'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026'],
];
const updAgostoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_AGOSTO_2026) upd02.run(newV, tag, oldV); });
updAgostoTx();
// Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services,
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
// Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'service.performance-class.title' AND value = 'Performance Class'").run();
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'footer.services.2.label' AND value = 'Performance Class'").run();
// Titolo del servizio One to One: in prod era stato editato da pannello a "Personal training"
// (t minuscola → la vecchia migrazione, guardata su 'Personal Training', non l'agganciava),
// così l'hero e l'H2 di /services/one-to-one mostravano "PERSONAL TRAINING". Riportiamo il
// nome canonico "One to One" (già nel seed). Guardato sul valore reale corrente → idempotente;
// la descrizione lunga editata da pannello NON viene toccata.
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal training'").run();
// Pulizia tag orfani: il riquadro training "performance-class" è stato rimosso dal seed
// (commit 9966ec3, "Training: 6 riquadri"), ma i suoi tag restano nei DB esistenti — orfani,
// non renderizzati da nessuna pagina. Rimuoviamo l'intero gruppo. Idempotente. Il seed non li
// reinserisce (trainings.ts non ha più questo id).
db.prepare("DELETE FROM content_blocks WHERE tag LIKE 'training.performance-class.%'").run();
// Modifiche 03: due ingressi dello stesso stabile → indirizzo esteso in footer e contatti.
// Il tag global.contact.map-query è nuovo (inserito dal seed); qui si aggiorna solo l'indirizzo
// già esistente, se non modificato dal pannello. Idempotente.
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
// Orario sabato corretto: 8–17, non 8–19. Guardato dal vecchio valore di default →
// idempotente, non tocca eventuali modifiche del pannello.
db.prepare("UPDATE content_blocks SET value = 'Sab 8.00 – 17.00 · Dom chiuso' WHERE tag = 'global.hours.2' AND value = 'Sab 8.00 – 19.00 · Dom chiuso'").run();
// Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
// rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta).
const ABOUT_02: [string, string, string][] = [
['about.about.body', '%unire fitness e salute in un unico percorso%',
"InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."],
['about.convenzioni.item1', '%small group training e classi di gruppo%',
'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'],
['about.convenzioni.item2', '%consulenza gratuita preliminare%',
'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'],
['about.studios.body1', '%della performance. Più che un semplice insieme%',
"IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."],
];
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
updAboutTx();
// ⚠️ Qui c'era la rinomina una tantum del ruolo `campus` in `piattaforme`. Tolta il
// 03/09/2026 con la separazione: quel ruolo non esiste più in questo sito, e la
// migrazione avrebbe scritto in `users` un valore che nessuna regola di lib/auth
// riconosce. Gli utenti delle aree riservate vivono nel repo InsanityLab-platforms, col loro database.
// Rimossa la variante di test /promo-eventi2 e la sua voce di menu (era la settima,
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
// FAQ "Come faccio ad acquistare i pacchetti?": coda con il rimando a WhatsApp aggiunta dopo
// il primo deploy → i DB esistenti hanno ancora la versione senza. Append guardato dalla fine
// del vecchio testo e dall'assenza della coda → idempotente, non tocca riscritture da pannello.
db.prepare(
`UPDATE content_blocks SET value = value || '<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'
WHERE tag = 'promo.faq.0.a' AND value LIKE '%effettuando il pagamento.'`,
).run();
// FAQ 1 e 2 riscritte (rimando alla prima domanda per l'acquisto, screening iniziale per
// One to One/Small Group, orari class entro Settembre). Guardia via frammento LIKE del vecchio
// testo → idempotente e rispettosa di eventuali riscritture da pannello.
const PROMO_FAQ_RISCRITTE: [string, string, string][] = [
['promo.faq.1.a', '%per prenotare le singole sessioni in base agli orari disponibili%',
'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'],
['promo.faq.2.a', '%vai nella schermata Home, clicca il servizio che hai acquistato%',
'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class saranno pubblicati entro Settembre. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'],
];
const updFaq = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
const updFaqTx = db.transaction(() => { for (const [tag, frag, newV] of PROMO_FAQ_RISCRITTE) updFaq.run(newV, tag, frag); });
updFaqTx();
// Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti.
const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
if (!colonnePost.some((c) => c.name === 'deleted_at')) {
db.exec('ALTER TABLE posts ADD COLUMN deleted_at TEXT');
}
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
bfTx();
// Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
return db; return db;
} }
+49 -10
View File
@@ -2,21 +2,23 @@ import type Database from 'better-sqlite3';
export interface Post { export interface Post {
id: number; title: string; slug: string; category: string; excerpt: string; id: number; title: string; slug: string; category: string; excerpt: string;
body_html: string; cover: string | null; draft: number; body_html: string; cover: string | null; draft: number; author_id: number | null;
published_at: string | null; created_at: string; updated_at: string; published_at: string | null; created_at: string; updated_at: string;
/** Momento in cui è finito nel cestino; null se attivo. */
deleted_at: string | null;
} }
export interface PostInput { export interface PostInput {
title: string; slug: string; category: string; excerpt: string; title: string; slug: string; category: string; excerpt: string;
body_html: string; cover?: string | null; draft: boolean; body_html: string; cover?: string | null; draft: boolean; author_id?: number | null;
} }
export function createPost(db: Database.Database, input: PostInput): number { export function createPost(db: Database.Database, input: PostInput): number {
const res = db.prepare( const res = db.prepare(
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at) `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id,
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)` CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 }); ).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
return Number(res.lastInsertRowid); return Number(res.lastInsertRowid);
} }
@@ -30,6 +32,16 @@ export function updatePost(db: Database.Database, id: number, input: PostInput):
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 }); ).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
} }
/** Sposta nel cestino: l'articolo sparisce dal sito e dall'elenco, ma resta recuperabile. */
export function trashPost(db: Database.Database, id: number): void {
db.prepare("UPDATE posts SET deleted_at = datetime('now') WHERE id = ?").run(id);
}
export function restorePost(db: Database.Database, id: number): void {
db.prepare('UPDATE posts SET deleted_at = NULL WHERE id = ?').run(id);
}
/** Cancellazione definitiva: si usa solo dal cestino. */
export function deletePost(db: Database.Database, id: number): void { export function deletePost(db: Database.Database, id: number): void {
db.prepare('DELETE FROM posts WHERE id = ?').run(id); db.prepare('DELETE FROM posts WHERE id = ?').run(id);
} }
@@ -39,7 +51,7 @@ export function getPostById(db: Database.Database, id: number): Post | undefined
} }
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined { export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined; return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0 AND deleted_at IS NULL').get(slug) as Post | undefined;
} }
export function listPublished( export function listPublished(
@@ -47,7 +59,8 @@ export function listPublished(
opts: { category?: string; page?: number; perPage?: number } = {}, opts: { category?: string; page?: number; perPage?: number } = {},
): { items: Post[]; total: number } { ): { items: Post[]; total: number } {
const { category, page = 1, perPage = 9 } = opts; const { category, page = 1, perPage = 9 } = opts;
const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0'; const base = 'WHERE draft = 0 AND deleted_at IS NULL';
const where = category ? `${base} AND category = @category` : base;
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`) const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
.get({ category }) as { n: number }).n; .get({ category }) as { n: number }).n;
const items = db.prepare( const items = db.prepare(
@@ -56,19 +69,45 @@ export function listPublished(
return { items, total }; return { items, total };
} }
/** Quanti articoli pubblicati ha ogni categoria, cestino escluso. */
export function countPublishedByCategory(db: Database.Database): Record<string, number> {
const righe = db.prepare(
'SELECT category, COUNT(*) AS n FROM posts WHERE draft = 0 AND deleted_at IS NULL GROUP BY category'
).all() as { category: string; n: number }[];
return Object.fromEntries(righe.map((r) => [r.category, r.n]));
}
export function listAllPosts(db: Database.Database): Post[] { export function listAllPosts(db: Database.Database): Post[] {
return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[]; return db.prepare('SELECT * FROM posts WHERE deleted_at IS NULL ORDER BY updated_at DESC').all() as Post[];
}
/** Articoli nel cestino: tutti per chi gestisce, i propri per gli altri. */
export function listTrashed(db: Database.Database, authorId?: number): Post[] {
return authorId === undefined
? db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC').all() as Post[]
: db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL AND author_id = ? ORDER BY deleted_at DESC')
.all(authorId) as Post[];
} }
export function listRecent(db: Database.Database, limit = 4): Post[] { export function listRecent(db: Database.Database, limit = 4): Post[] {
return db.prepare( return db.prepare(
'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?' 'SELECT * FROM posts WHERE draft = 0 AND deleted_at IS NULL ORDER BY published_at DESC LIMIT ?'
).all(limit) as Post[]; ).all(limit) as Post[];
} }
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] { export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
return db.prepare( return db.prepare(
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count `SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC` FROM posts WHERE draft = 0 AND deleted_at IS NULL GROUP BY month ORDER BY month DESC`
).all() as { month: string; count: number }[]; ).all() as { month: string; count: number }[];
} }
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
return db.prepare('SELECT * FROM posts WHERE author_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC')
.all(authorId) as Post[];
}
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
if (role === 'admin' || role === 'superuser') return true;
return post.author_id === userId;
}
+7
View File
@@ -15,3 +15,10 @@ export function rateLimit(key: string, max: number, windowMs: number): boolean {
export function _resetBuckets(): void { export function _resetBuckets(): void {
buckets.clear(); buckets.clear();
} }
// Dietro Traefik `clientAddress` è l'IP interno del proxy, uguale per tutti:
// usiamo il primo IP di X-Forwarded-For (client reale) come chiave rate-limit.
export function clientIp(request: Request, fallback: string): string {
const first = request.headers.get('x-forwarded-for')?.split(',')[0].trim();
return first || fallback;
}
+2 -2
View File
@@ -1,9 +1,9 @@
export const STYLE_GROUPS = { export const STYLE_GROUPS = {
size: ['s', 'm', 'l'], size: ['s', 'm', 'l', 'xl', 'xxl'],
color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'], color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'],
weight: ['normal', 'bold'], weight: ['normal', 'bold'],
style: ['normal', 'italic'], style: ['normal', 'italic'],
align: ['left', 'center', 'right'], align: ['left', 'center', 'right', 'justify'],
} as const; } as const;
export type StyleGroup = keyof typeof STYLE_GROUPS; export type StyleGroup = keyof typeof STYLE_GROUPS;
+5 -6
View File
@@ -1,13 +1,10 @@
import { defineMiddleware } from 'astro:middleware'; import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db'; import { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath } from './lib/auth'; import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor, isProtectedPath } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => { export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url; const { pathname } = context.url;
const isProtected = if (!isProtectedPath(pathname)) return next();
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin');
if (!isProtected) return next();
const token = context.cookies.get(SESSION_COOKIE)?.value; const token = context.cookies.get(SESSION_COOKIE)?.value;
const user = token ? getSessionUser(getDb(), token) : null; const user = token ? getSessionUser(getDb(), token) : null;
@@ -17,6 +14,8 @@ export const onRequest = defineMiddleware((context, next) => {
status: 401, headers: { 'Content-Type': 'application/json' }, status: 401, headers: { 'Content-Type': 'application/json' },
}); });
} }
// ⚠️ Qui resta un solo login, quello del pannello: il login pubblico serviva a portare
// i clienti dentro le aree riservate, che dal 03/09/2026 stanno su apps.insanitylab.it.
return context.redirect('/admin/login'); return context.redirect('/admin/login');
} }
@@ -26,7 +25,7 @@ export const onRequest = defineMiddleware((context, next) => {
status: 403, headers: { 'Content-Type': 'application/json' }, status: 403, headers: { 'Content-Type': 'application/json' },
}); });
} }
return context.redirect('/admin/content'); return context.redirect(landingFor(user.role));
} }
context.locals.user = user; context.locals.user = user;
+9 -41
View File
@@ -2,7 +2,6 @@
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro'; import PageHero from '../components/PageHero.astro';
import Timeline from '../components/about/Timeline.astro';
import { team } from '../data/team'; import { team } from '../data/team';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
import TImg from '../components/content/TImg.astro'; import TImg from '../components/content/TImg.astro';
@@ -63,8 +62,6 @@ export const prerender = false;
</div> </div>
</section> </section>
<Timeline />
<section class="section section--dark"> <section class="section section--dark">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
@@ -121,25 +118,6 @@ export const prerender = false;
</div> </div>
</section> </section>
<section class="section section--alt">
<div class="container">
<div class="section-heading">
<T tag="about.metodo.title" as="h2" />
<T tag="about.metodo.body" as="p" />
</div>
<div class="about-img-row">
<div class="about-img-row__text section-heading">
<T tag="about.metodo.closing" as="p" />
</div>
{imgOf('about-team-group') && (
<div class="round-img">
<TImg tag="about.metodo.image" src={imgOf('about-team-group')!} alt="Il team InsanityLab" widths={[360, 720]} sizes="360px" />
</div>
)}
</div>
</div>
</section>
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="about-img-row"> <div class="about-img-row">
@@ -158,22 +136,6 @@ export const prerender = false;
</div> </div>
</section> </section>
<section class="section section--alt">
<div class="container">
<div class="about-img-row">
{imgOf('about-vision') && (
<div class="round-img">
<TImg tag="about.vision.image" src={imgOf('about-vision')!} alt="La comunità InsanityLab" widths={[360, 720]} sizes="360px" />
</div>
)}
<div class="about-img-row__text section-heading">
<T tag="about.vision.title" as="h2" />
<T tag="about.vision.body1" as="p" />
<T tag="about.vision.body2" as="p" />
</div>
</div>
</div>
</section>
</Base> </Base>
<style> <style>
@@ -187,15 +149,21 @@ export const prerender = false;
.conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; } .conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
.round-img { max-width: 380px; margin: 30px auto 0; } .round-img { max-width: 380px; margin: 30px auto 0; }
.round-img img { border-radius: 50%; aspect-ratio: 1; object-fit: cover; width: 100%; } .round-img img { border-radius: 50%; aspect-ratio: 1; object-fit: cover; width: 100%; }
.tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; } /* pre-line preserva gli a-capo del testo (il <p> reso da <T> non eredita il cid → :global). */
.tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; } .about-img-row__text :global([data-tag="about.studios.body3"]) { white-space: pre-line; }
/* flex + center così l'ultima riga incompleta (es. staff: 5 membri → 2 nell'ultima riga)
resta centrata invece di appoggiarsi a sinistra. */
.tgrid { display: flex; flex-wrap: wrap; justify-content: center; gap: 40px; }
.tcard { width: calc((100% - 80px) / 3); }
/* :global(img) perché gli <img> di override resi da <TImg> non ereditano il cid. */
.tcard :global(img) { aspect-ratio: 3/4; object-fit: cover; width: 100%; margin-bottom: 16px; }
.tcard h3 { text-transform: lowercase; letter-spacing: .2em; } .tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
.tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); } .tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
.tcard p { font-size: .9rem; } .tcard p { font-size: .9rem; }
@media (max-width: 767px) { @media (max-width: 767px) {
.about-2col, .about-2col,
.tgrid,
.about-img-row { grid-template-columns: 1fr; } .about-img-row { grid-template-columns: 1fr; }
.tcard { width: 100%; }
.about-img-row--reverse { direction: ltr; } .about-img-row--reverse { direction: ltr; }
} }
</style> </style>
+70
View File
@@ -0,0 +1,70 @@
---
// Cestino degli articoli: qui si recupera ciò che è stato tolto dal sito, o lo si elimina
// per sempre. Chi gestisce vede tutto, gli altri solo i propri articoli.
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { listTrashed } from '../../lib/posts';
import { listUsers } from '../../lib/auth';
export const prerender = false;
const user = Astro.locals.user!;
const isManager = user.role === 'superuser' || user.role === 'admin';
const posts = listTrashed(getDb(), isManager ? undefined : user.id);
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
---
<Admin title="Cestino">
<h1>Cestino</h1>
<p class="atoolbar"><a class="abtn abtn--ghost" href="/admin">← Torna agli articoli</a></p>
{posts.length === 0 ? (
<p class="amuted">Il cestino è vuoto.</p>
) : (
<table>
<thead>
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Nel cestino dal</th><th class="uactions">Azioni</th></tr>
</thead>
<tbody>
{posts.map((p) => (
<tr>
<td>{p.title}</td>
<td>{p.category}</td>
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td>{p.deleted_at ? fmt.format(new Date(p.deleted_at.replace(' ', 'T') + 'Z')) : '—'}</td>
<td class="uactions">
<button class="aicon" data-restore={p.id} title="Ripristina" aria-label={`Ripristina ${p.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z"/></svg>
</button>
<button class="aicon aicon--danger" data-purge={p.id} title="Elimina definitivamente" aria-label={`Elimina definitivamente ${p.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<p class="form-msg" id="row-msg"></p>
</Admin>
<script>
const rowMsg = document.getElementById('row-msg')!;
const errore = async (res: Response) => {
rowMsg.className = 'form-msg form-msg--err';
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
};
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((b) =>
b.addEventListener('click', async () => {
const res = await fetch(`/api/admin/posts/${b.dataset.restore}`, { method: 'POST' });
if (res.ok) location.reload();
else errore(res);
}));
document.querySelectorAll<HTMLButtonElement>('[data-purge]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo articolo? Il contenuto sarà perso.')) return;
const res = await fetch(`/api/admin/posts/${b.dataset.purge}?definitivo=1`, { method: 'DELETE' });
if (res.ok) location.reload();
else errore(res);
}));
</script>
+13 -9
View File
@@ -109,22 +109,26 @@ const allStyles = STYLE_GROUPS as Record<string, readonly string[]>;
<style is:global> <style is:global>
.chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; } .chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
.chead a { color: #7a6a55; } .chead a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
.cgroup { background: #fff; border: 1px solid #ddd; margin-bottom: 14px; } .cgroup { background: #fff; border: 1px solid #e4dfd7; margin-bottom: 14px; }
.cgroup > summary { padding: 12px 16px; cursor: pointer; background: #eee9e1; font-size: 1.05rem; } .cgroup > summary {
.crow { border-top: 1px solid #eee; } padding: 13px 16px; cursor: pointer; background: #faf8f5; border-bottom: 1px solid #efeae2;
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: var(--c-heading);
}
.crow { border-top: 1px solid #efeae2; }
.crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; } .crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; }
.crow > summary code { background: #f0ece5; padding: 1px 6px; font-size: .82rem; white-space: nowrap; } .crow > summary code { background: #f2ede5; padding: 2px 7px; font-size: .78rem; white-space: nowrap; color: #6f6357; }
.crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crow-body { padding: 6px 16px 12px; } .crow-body { padding: 6px 16px 12px; }
.crow-body .afield { margin-bottom: 8px; } .crow-body .afield { margin-bottom: 8px; }
.cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; } .cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; }
.cstyles label { font-size: .85rem; color: #555; } .cstyles label { font-size: .82rem; color: var(--c-text-light); }
.cstyles select { margin-left: 4px; padding: 4px; font: inherit; } .cstyles select { margin-left: 4px; padding: 4px; font: inherit; }
.cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #ddd; } .cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #e4dfd7; }
.cmeta { color: #999; font-size: .78rem; margin: 4px 0 0; } .cmeta { color: var(--c-text-light); font-size: .76rem; margin: 4px 0 0; }
.crow--hl { outline: 2px solid #b5a48b; } .crow--hl { outline: 2px solid var(--c-accent); }
</style> </style>
<script> <script>
+3 -1
View File
@@ -2,10 +2,12 @@
import Admin from '../../../layouts/Admin.astro'; import Admin from '../../../layouts/Admin.astro';
import PostForm from '../../../components/admin/PostForm.astro'; import PostForm from '../../../components/admin/PostForm.astro';
import { getDb } from '../../../lib/db'; import { getDb } from '../../../lib/db';
import { getPostById } from '../../../lib/posts'; import { getPostById, canModifyPost } from '../../../lib/posts';
export const prerender = false; export const prerender = false;
const post = getPostById(getDb(), Number(Astro.params.id)); const post = getPostById(getDb(), Number(Astro.params.id));
if (!post) return Astro.redirect('/admin'); if (!post) return Astro.redirect('/admin');
const u = Astro.locals.user!;
if (!canModifyPost(u.role, u.id, post)) return Astro.redirect('/admin');
--- ---
<Admin title={`Modifica: ${post.title}`}> <Admin title={`Modifica: ${post.title}`}>
<h1>Modifica articolo</h1> <h1>Modifica articolo</h1>
+34 -8
View File
@@ -1,37 +1,63 @@
--- ---
import Admin from '../../layouts/Admin.astro'; import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { listAllPosts } from '../../lib/posts'; import { listAllPosts, listByAuthor, listTrashed } from '../../lib/posts';
import { listUsers } from '../../lib/auth';
export const prerender = false; export const prerender = false;
const posts = listAllPosts(getDb()); const user = Astro.locals.user!;
const isManager = user.role === 'superuser' || user.role === 'admin';
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
const cestinati = listTrashed(getDb(), isManager ? undefined : user.id).length;
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' }); const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
--- ---
<Admin title="Articoli"> <Admin title="Articoli">
<h1>Articoli</h1> <h1>Articoli</h1>
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
<p class="atoolbar">
<a class="abtn" href="/admin/new">+ Nuovo articolo</a>
{cestinati > 0 && <a class="abtn abtn--ghost" href="/admin/cestino">Cestino ({cestinati})</a>}
</p>
<table> <table>
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead> <thead>
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th class="uactions">Azioni</th></tr>
</thead>
<tbody> <tbody>
{posts.map((p) => ( {posts.map((p) => (
<tr> <tr>
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td> <td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
<td>{p.category}</td> <td>{p.category}</td>
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td> <td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td> <td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td> <td class="uactions">
<a class="aicon" href={`/admin/edit/${p.id}`} title="Modifica" aria-label={`Modifica ${p.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 17 9.5-9.5 3 3L7 20H4v-3Zm11.7-11.7 1.6-1.6a1.4 1.4 0 0 1 2 0l1 1a1.4 1.4 0 0 1 0 2l-1.6 1.6-3-3Z"/></svg>
</a>
<button class="aicon aicon--danger" data-del={p.id} title="Sposta nel cestino" aria-label={`Sposta ${p.title} nel cestino`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>} {posts.length === 0 && <p class="amuted">Nessun articolo: creane uno nuovo.</p>}
<p class="form-msg" id="row-msg"></p>
</Admin> </Admin>
<script> <script>
const rowMsg = document.getElementById('row-msg')!;
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) => document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
b.addEventListener('click', async () => { b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo articolo?')) return; if (!confirm('Spostare questo articolo nel cestino? Sparirà dal sito, ma potrai recuperarlo.')) return;
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' }); const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
if (res.ok) location.reload(); if (res.ok) location.reload();
else alert('Eliminazione non riuscita.'); else {
rowMsg.className = 'form-msg form-msg--err';
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
}
})); }));
</script> </script>
+19 -11
View File
@@ -1,32 +1,40 @@
--- ---
import Admin from '../../layouts/Admin.astro'; import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { login, SESSION_COOKIE } from '../../lib/auth'; import { login, getSessionUser, logout, landingFor, isRole, SESSION_COOKIE } from '../../lib/auth';
import { rateLimit } from '../../lib/rate-limit';
export const prerender = false; export const prerender = false;
let error = ''; let error = '';
if (Astro.request.method === 'POST') { if (Astro.request.method === 'POST') {
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) { const form = await Astro.request.formData();
error = 'Troppi tentativi: riprova tra 15 minuti.'; const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
} else { if (token) {
const form = await Astro.request.formData(); const utente = getSessionUser(getDb(), token);
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? '')); // Un account delle aree riservate rimasto in questo database (i ruoli piattaforme,
if (token) { // cliente e trainer sono emigrati il 03/09/2026) non deve entrare qui.
// Senza questo controllo la password sarebbe giusta, la sessione verrebbe creata, e
// poi il middleware lo rimbalzerebbe su landingFor -> /admin -> di nuovo il middleware:
// un CICLO di redirect, che sembra un guasto del sito invece di un account nel posto
// sbagliato. La sessione appena creata va chiusa, non lasciata aperta a vuoto.
if (!utente || !isRole(utente.role)) {
logout(getDb(), token);
error = 'Questo account appartiene all area riservata: si accede da apps.insanitylab.it.';
} else {
Astro.cookies.set(SESSION_COOKIE, token, { Astro.cookies.set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/', httpOnly: true, sameSite: 'lax', path: '/',
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
}); });
return Astro.redirect('/admin'); return Astro.redirect(landingFor(utente.role));
} }
} else {
error = 'Credenziali non valide.'; error = 'Credenziali non valide.';
} }
} }
--- ---
<Admin title="Login"> <Admin title="Login">
<h1>Accesso</h1> <h1>Accesso</h1>
{error && <p style="color:#a33">{error}</p>} {error && <p class="form-msg form-msg--err">{error}</p>}
<form method="post" style="max-width:360px"> <form class="aform" method="post">
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" /> <input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" /> <input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button class="abtn" type="submit">Entra</button> <button class="abtn" type="submit">Entra</button>
+1 -1
View File
@@ -7,5 +7,5 @@ export const GET: APIRoute = ({ cookies, redirect }) => {
const token = cookies.get(SESSION_COOKIE)?.value; const token = cookies.get(SESSION_COOKIE)?.value;
if (token) logout(getDb(), token); if (token) logout(getDb(), token);
cookies.delete(SESSION_COOKIE, { path: '/' }); cookies.delete(SESSION_COOKIE, { path: '/' });
return redirect('/admin/login'); return redirect('/');
}; };
+165
View File
@@ -0,0 +1,165 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { listUsers, ROLES } from '../../lib/auth';
export const prerender = false;
const users = listUsers(getDb());
const me = Astro.locals.user!;
---
<Admin title="Utenti">
<h1>Utenti</h1>
<p><a class="abtn" href="/admin/users/new">+ Nuovo utente</a></p>
<!-- Pannello password: si apre dall'icona a chiave della riga. Permette di scegliere una
password o di farla generare; il risultato si vede una volta sola, perché nel database
finisce solo il suo hash. -->
<div class="apw" id="pwbox" hidden>
<p class="apw__label">Password di <strong id="pwbox-user"></strong></p>
<div id="pwbox-form">
<div class="apw__row">
<input class="afield apw__input" type="text" id="pwbox-input" placeholder="Scrivi una password (min 8)" autocomplete="off" />
<button class="abtn" type="button" id="pwbox-set">Imposta</button>
<button class="abtn abtn--ghost" type="button" id="pwbox-gen">Genera casuale</button>
<button class="abtn abtn--ghost" type="button" id="pwbox-close">Annulla</button>
</div>
<p class="apw__hint">La password attuale smetterà di funzionare.</p>
<p class="form-msg" id="pwbox-msg" hidden></p>
</div>
<div id="pwbox-result" hidden>
<div class="apw__row">
<code id="pwbox-value"></code>
<button class="abtn" type="button" id="pwbox-copy">Copia</button>
<button class="abtn abtn--ghost" type="button" id="pwbox-done">Chiudi</button>
</div>
<p class="apw__hint">Consegnala ora: chiuso questo riquadro non è più recuperabile.</p>
</div>
</div>
<table>
<thead><tr><th>Utente</th><th>Ruolo</th><th class="uactions">Azioni</th></tr></thead>
<tbody>
{users.map((u) => (
<tr data-id={u.id}>
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td>
<select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}>
{ROLES.map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
</select>
</td>
<td class="uactions">
<button class="aicon" data-reset={u.id} title="Genera una nuova password" aria-label={`Genera una nuova password per ${u.username}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 7a4 4 0 1 1-3.9 5H7v2H5v2H2v-3l5.1-5.1A4 4 0 0 1 14 7Z"/><circle cx="15.5" cy="9.5" r="1.2" fill="#fff"/></svg>
</button>
{u.id !== me.id && (
<button class="aicon aicon--danger" data-del={u.id} title="Elimina utente" aria-label={`Elimina ${u.username}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<p class="form-msg" id="row-msg"></p>
</Admin>
<script>
const rowMsg = document.getElementById('row-msg')!;
const show = (el: HTMLElement, text: string, ok = false) => {
el.textContent = text;
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
};
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
sel.addEventListener('change', async () => {
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
});
if (res.ok) { sel.dataset.role = sel.value; show(rowMsg, 'Ruolo aggiornato', true); }
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
}));
const pwbox = document.getElementById('pwbox')!;
const pwUser = document.getElementById('pwbox-user')!;
const pwValue = document.getElementById('pwbox-value')!;
const pwForm = document.getElementById('pwbox-form')!;
const pwResult = document.getElementById('pwbox-result')!;
const pwInput = document.getElementById('pwbox-input') as HTMLInputElement;
const pwMsg = document.getElementById('pwbox-msg')!;
const pwCopy = document.getElementById('pwbox-copy') as HTMLButtonElement;
let idCorrente = '';
const chiudi = () => { pwbox.hidden = true; };
document.getElementById('pwbox-close')!.addEventListener('click', chiudi);
document.getElementById('pwbox-done')!.addEventListener('click', chiudi);
/** Manda la nuova password: senza corpo il server ne genera una a caso. */
async function cambiaPassword(password?: string): Promise<void> {
const res = await fetch(`/api/admin/users/${idCorrente}/reset-password`, {
method: 'POST',
...(password
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }
: {}),
});
if (!res.ok) {
pwMsg.hidden = false;
pwMsg.className = 'form-msg form-msg--err';
pwMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
return;
}
const dati = await res.json();
pwValue.textContent = dati.password;
pwForm.hidden = true;
pwResult.hidden = false;
}
document.getElementById('pwbox-set')!.addEventListener('click', () => {
const scelta = pwInput.value.trim();
if (scelta.length < 8) {
pwMsg.hidden = false;
pwMsg.className = 'form-msg form-msg--err';
pwMsg.textContent = 'Password troppo corta (min 8).';
return;
}
cambiaPassword(scelta);
});
document.getElementById('pwbox-gen')!.addEventListener('click', () => cambiaPassword());
pwCopy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pwValue.textContent ?? '');
pwCopy.textContent = 'Copiata';
setTimeout(() => { pwCopy.textContent = 'Copia'; }, 1500);
} catch {
// Senza permesso sugli appunti resta la selezione manuale: evidenziamo il testo.
const range = document.createRange();
range.selectNodeContents(pwValue);
getSelection()?.removeAllRanges();
getSelection()?.addRange(range);
}
});
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
b.addEventListener('click', () => {
idCorrente = b.dataset.reset!;
pwUser.textContent = (b.closest('tr')!.querySelector('td')!.textContent ?? '').replace(' (tu)', '');
pwInput.value = '';
pwMsg.hidden = true;
pwResult.hidden = true;
pwForm.hidden = false;
pwbox.hidden = false;
pwbox.scrollIntoView({ block: 'nearest' });
pwInput.focus();
}));
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo utente?')) return;
const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
if (res.ok) location.reload();
else show(rowMsg, (await res.json()).error ?? 'Errore');
}));
</script>
+64
View File
@@ -0,0 +1,64 @@
---
// Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e
// il modulo ha spazio per le sue spiegazioni.
import Admin from '../../../layouts/Admin.astro';
import { ROLES } from '../../../lib/auth';
export const prerender = false;
// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina
// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto.
const DESCRIZIONI: Partial<Record<(typeof ROLES)[number], string>> = {
user: 'scrive i propri articoli',
superuser: 'articoli e contenuti del sito',
admin: 'accesso completo',
};
---
<Admin title="Nuovo utente">
<h1>Nuovo utente</h1>
<form class="aform" id="new-user">
<label class="alabel" for="username">Nome utente</label>
<input class="afield" id="username" name="username" required autocomplete="off" />
<label class="alabel" for="password">Password</label>
<input class="afield" id="password" name="password" type="text" required minlength="8" autocomplete="off" />
<p class="ahint">Almeno 8 caratteri. Resta in chiaro qui: copiala e consegnala alla persona.</p>
<label class="alabel" for="role">Ruolo</label>
<select class="afield" id="role" name="role">
{ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
</select>
<div class="aactions">
<button class="abtn" type="submit">Crea utente</button>
<a class="abtn abtn--ghost" href="/admin/users">Annulla</a>
</div>
<p class="form-msg" id="new-msg" hidden></p>
</form>
</Admin>
<script>
const form = document.getElementById('new-user') as HTMLFormElement;
const msg = document.getElementById('new-msg') as HTMLElement;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const dati = new FormData(form);
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: String(dati.get('username') ?? ''),
password: String(dati.get('password') ?? ''),
role: String(dati.get('role') ?? 'user'),
}),
});
if (res.ok) {
location.href = '/admin/users';
return;
}
msg.hidden = false;
msg.className = 'form-msg form-msg--err';
msg.textContent = (await res.json()).error ?? 'Creazione non riuscita.';
});
</script>
+21
View File
@@ -2,11 +2,32 @@ import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db'; import { getDb } from '../../../../lib/db';
import { applyContentUpdate } from '../../../../lib/content-update'; import { applyContentUpdate } from '../../../../lib/content-update';
import { invalidateContentCache } from '../../../../lib/content'; import { invalidateContentCache } from '../../../../lib/content';
import { seedByTag } from '../../../../data/content-seed';
export const prerender = false; export const prerender = false;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = ({ params }) => {
const tag = params.tag ?? '';
const row = getDb().prepare('SELECT tag, type, value, styles, guid FROM content_blocks WHERE tag = ?').get(tag) as
{ tag: string; type: string; value: string; styles: string; guid: string | null } | undefined;
if (!row) return json(404, { error: 'Tag inesistente.' });
let styles: Record<string, string> = {};
try {
const parsed = JSON.parse(row.styles);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) styles = parsed;
} catch { /* styles corrotto: si ignora */ }
return json(200, {
tag: row.tag,
guid: row.guid ?? '',
type: row.type,
value: row.value,
styles,
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
});
};
export const PUT: APIRoute = async ({ params, request, locals }) => { export const PUT: APIRoute = async ({ params, request, locals }) => {
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+32 -5
View File
@@ -1,15 +1,17 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db'; import { getDb } from '../../../../lib/db';
import { getPostById, updatePost, deletePost } from '../../../../lib/posts'; import { getPostById, updatePost, deletePost, trashPost, restorePost, canModifyPost } from '../../../../lib/posts';
import { parsePostBody } from '../../../../lib/post-body'; import { parsePostBody } from '../../../../lib/post-body';
export const prerender = false; export const prerender = false;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const PUT: APIRoute = async ({ params, request }) => { export const PUT: APIRoute = async ({ params, request, locals }) => {
const id = Number(params.id); const id = Number(params.id);
if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' }); const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi modificare questo articolo.' });
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const parsed = parsePostBody(data); const parsed = parsePostBody(data);
@@ -23,7 +25,32 @@ export const PUT: APIRoute = async ({ params, request }) => {
} }
}; };
export const DELETE: APIRoute = ({ params }) => { /**
deletePost(getDb(), Number(params.id)); * Sposta l'articolo nel cestino. Con `?definitivo=1` lo cancella davvero: è la sola via per
* perdere il contenuto, e vale solo per ciò che è già nel cestino.
*/
export const DELETE: APIRoute = ({ params, locals, url }) => {
const id = Number(params.id);
const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
if (url.searchParams.get('definitivo') === '1') {
if (!existing.deleted_at) return json(400, { error: 'Sposta prima l\'articolo nel cestino.' });
deletePost(getDb(), id);
return json(200, { ok: true, definitivo: true });
}
trashPost(getDb(), id);
return json(200, { ok: true });
};
/** Riporta l'articolo fuori dal cestino, nello stato in cui era (bozza o pubblicato). */
export const POST: APIRoute = ({ params, locals }) => {
const id = Number(params.id);
const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi ripristinare questo articolo.' });
restorePost(getDb(), id);
return json(200, { ok: true }); return json(200, { ok: true });
}; };
+2 -2
View File
@@ -7,13 +7,13 @@ export const prerender = false;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request, locals }) => {
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const parsed = parsePostBody(data); const parsed = parsePostBody(data);
if ('error' in parsed) return json(400, { error: parsed.error }); if ('error' in parsed) return json(400, { error: parsed.error });
try { try {
const id = createPost(getDb(), parsed.value); const id = createPost(getDb(), { ...parsed.value, author_id: locals.user!.id });
return json(201, { id, slug: parsed.value.slug }); return json(201, { id, slug: parsed.value.slug });
} catch (err: any) { } catch (err: any) {
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' }); if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+18
View File
@@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, cookies, locals }) => {
const role = locals.user!.role;
if (role !== 'superuser' && role !== 'admin') return json(403, { error: 'Permessi insufficienti' });
let data: { on?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (data.on) {
cookies.set('showtags', '1', { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD });
} else {
cookies.delete('showtags', { path: '/' });
}
return json(200, { on: Boolean(data.on) });
};
+19
View File
@@ -0,0 +1,19 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../../lib/db';
import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const DELETE: APIRoute = ({ params, locals }) => {
const id = Number(params.id);
const target = getUserById(getDb(), id);
if (!target) return json(404, { error: 'Utente inesistente.' });
if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' });
if (target.role === 'admin' && countAdmins(getDb()) <= 1) {
return json(400, { error: 'Deve restare almeno un admin.' });
}
deleteUser(getDb(), id);
return json(200, { ok: true });
};
@@ -0,0 +1,32 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../../lib/db';
import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
/**
* Cambia la password di un utente. Senza corpo la genera a caso; con `{ password }` usa
* quella scelta dall'admin. In entrambi i casi la risposta la riporta in chiaro una volta
* sola: nel database finisce solo il suo hash.
*/
export const POST: APIRoute = async ({ params, request }) => {
const id = Number(params.id);
if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
let scelta = '';
if (request.headers.get('content-type')?.includes('application/json')) {
try {
const data = (await request.json()) as { password?: unknown };
scelta = String(data.password ?? '');
} catch {
return json(400, { error: 'Richiesta non valida.' });
}
}
if (scelta && scelta.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
const password = scelta || randomPassword();
updateUserPassword(getDb(), id, hashPassword(password));
return json(200, { password });
};
+21
View File
@@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../../lib/db';
import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const PUT: APIRoute = async ({ params, request }) => {
const id = Number(params.id);
const target = getUserById(getDb(), id);
if (!target) return json(404, { error: 'Utente inesistente.' });
let data: { role?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) {
return json(400, { error: 'Deve restare almeno un admin.' });
}
updateUserRole(getDb(), id, data.role);
return json(200, { ok: true });
};
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { createUser, listUsers, isRole } from '../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) });
export const POST: APIRoute = async ({ request }) => {
let data: { username?: unknown; password?: unknown; role?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const username = String(data.username ?? '').trim();
const password = String(data.password ?? '');
if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' });
if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
try {
const id = createUser(getDb(), username, password, data.role);
return json(201, { id });
} catch (err: any) {
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' });
throw err;
}
};
+6 -4
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { validateContact } from '../../lib/contact'; import { validateContact } from '../../lib/contact';
import { sendContactEmail } from '../../lib/mailer'; import { sendContactEmail } from '../../lib/mailer';
import { rateLimit } from '../../lib/rate-limit'; import { rateLimit, clientIp } from '../../lib/rate-limit';
export const prerender = false; export const prerender = false;
@@ -9,13 +9,15 @@ const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, clientAddress }) => { export const POST: APIRoute = async ({ request, clientAddress }) => {
if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
}
let data: unknown; let data: unknown;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const result = validateContact(data); const result = validateContact(data);
if (!result.ok) return json(400, { error: result.error }); if (!result.ok) return json(400, { error: result.error });
// Rate-limit solo sugli invii validi, per IP reale: bot/honeypot non
// consumano la quota di un utente legittimo.
if (!rateLimit(`contact:${clientIp(request, clientAddress)}`, 5, 60 * 60 * 1000)) {
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
}
try { try {
await sendContactEmail(result.value); await sendContactEmail(result.value);
} catch (err) { } catch (err) {
+4 -1
View File
@@ -3,18 +3,21 @@ import Base from '../../layouts/Base.astro';
import Sidebar from '../../components/blog/Sidebar.astro'; import Sidebar from '../../components/blog/Sidebar.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { getPublishedBySlug } from '../../lib/posts'; import { getPublishedBySlug } from '../../lib/posts';
import { getUsernameById } from '../../lib/auth';
export const prerender = false; export const prerender = false;
const post = getPublishedBySlug(getDb(), Astro.params.slug!); const post = getPublishedBySlug(getDb(), Astro.params.slug!);
if (!post) return new Response(null, { status: 404 }); if (!post) return new Response(null, { status: 404 });
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }); const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null;
const byline = author ?? 'Staff InsanityLab';
--- ---
<Base title={post.title} description={post.excerpt}> <Base title={post.title} description={post.excerpt}>
<section class="section"> <section class="section">
<div class="container art"> <div class="container art">
<article> <article>
{post.cover && <img class="art__cover" src={post.cover} alt="" />} {post.cover && <img class="art__cover" src={post.cover} alt="" />}
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p> <p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
<h1>{post.title}</h1> <h1>{post.title}</h1>
<div class="art__body" set:html={post.body_html} /> <div class="art__body" set:html={post.body_html} />
</article> </article>
+71 -4
View File
@@ -2,7 +2,7 @@
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro'; import PageHero from '../../components/PageHero.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { listPublished } from '../../lib/posts'; import { listPublished, countPublishedByCategory } from '../../lib/posts';
import { CATEGORIES } from '../../lib/blog-const'; import { CATEGORIES } from '../../lib/blog-const';
export const prerender = false; export const prerender = false;
@@ -15,6 +15,9 @@ const { items, total } = listPublished(getDb(), {
page, perPage: PER_PAGE, page, perPage: PER_PAGE,
}); });
const pages = Math.max(1, Math.ceil(total / PER_PAGE)); const pages = Math.max(1, Math.ceil(total / PER_PAGE));
// Conteggi per categoria: mostrati accanto ai filtri, così si vede subito dove c'è materiale.
const perCategoria = countPublishedByCategory(getDb());
const totalePubblicati = Object.values(perCategoria).reduce((n, v) => n + v, 0);
const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : ''; const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
const linkFor = (cat?: string, p = 1) => { const linkFor = (cat?: string, p = 1) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
@@ -33,13 +36,18 @@ const linkFor = (cat?: string, p = 1) => {
<p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p> <p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
</div> </div>
<nav class="bfilters" aria-label="categorie"> <nav class="bfilters" aria-label="categorie">
<a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>Mostra tutto</a> <a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>
Mostra tutto <span class="bfilters__n">({totalePubblicati})</span>
</a>
{CATEGORIES.map((c) => ( {CATEGORIES.map((c) => (
<a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>{c}</a> <a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>
{c} <span class="bfilters__n">({perCategoria[c] ?? 0})</span>
</a>
))} ))}
</nav> </nav>
<div id="blog-results">
{items.length === 0 ? ( {items.length === 0 ? (
<p style="text-align:center">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p> <p class="bempty">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
) : ( ) : (
<div class="bgrid"> <div class="bgrid">
{items.map((post) => ( {items.map((post) => (
@@ -60,14 +68,73 @@ const linkFor = (cat?: string, p = 1) => {
))} ))}
</nav> </nav>
)} )}
</div>
</div> </div>
</section> </section>
</Base> </Base>
<script>
// Cambio categoria e pagina senza ricaricare: si scarica la stessa pagina e si sostituiscono
// solo la griglia e i filtri. I link restano normali, quindi senza JS tutto continua a
// funzionare come prima.
const risultati = document.getElementById('blog-results');
const filtri = document.querySelector('.bfilters');
if (risultati && filtri) {
let inCorso: AbortController | null = null;
async function mostra(href: string, scorri: boolean): Promise<void> {
inCorso?.abort();
const controller = new AbortController();
inCorso = controller;
risultati!.classList.add('is-loading');
try {
const res = await fetch(href, { signal: controller.signal });
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const nuoviRisultati = doc.getElementById('blog-results');
const nuoviFiltri = doc.querySelector('.bfilters');
// Se la pagina non ha la forma attesa (errore, redirect) si lascia fare al browser.
if (!nuoviRisultati || !nuoviFiltri) { location.href = href; return; }
risultati!.innerHTML = nuoviRisultati.innerHTML;
filtri!.innerHTML = nuoviFiltri.innerHTML;
if (scorri) filtri!.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (e) {
if ((e as Error).name === 'AbortError') return;
location.href = href;
return;
} finally {
if (inCorso === controller) {
risultati!.classList.remove('is-loading');
inCorso = null;
}
}
}
// Delega sul documento: i link vengono rigenerati a ogni cambio, così non serve riagganciarli.
document.addEventListener('click', (e) => {
const link = (e.target as Element).closest<HTMLAnchorElement>('.bfilters__item, .bpages__n');
if (!link || e.metaKey || e.ctrlKey || e.shiftKey || (e as MouseEvent).button !== 0) return;
e.preventDefault();
if (link.classList.contains('is-active')) return;
history.pushState({}, '', link.href);
mostra(link.href, link.classList.contains('bpages__n'));
});
// Avanti/indietro del browser: si ricarica il contenuto senza toccare la cronologia.
addEventListener('popstate', () => mostra(location.href, false));
}
</script>
<style> <style>
.bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; } .bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
.bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; } .bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
.bfilters__item.is-active { background: var(--c-accent); color: #fff; } .bfilters__item.is-active { background: var(--c-accent); color: #fff; }
.bfilters__n { color: var(--c-text-light); }
.bfilters__item.is-active .bfilters__n { color: rgba(255,255,255,.75); }
.bfilters__item { transition: background .2s ease, color .2s ease; }
#blog-results { transition: opacity .25s ease; }
#blog-results.is-loading { opacity: .35; }
.bempty { text-align: center; }
.bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; } .bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
.bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; } .bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
.bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; } .bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
+41
View File
@@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari';
import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf';
export const prerender = false;
// Il PDF degli orari, come file scaricabile.
//
// /calendario/tutti.pdf tutti gli orari validi, uno per pagina
// /calendario/settembre-1-18.pdf un orario solo
//
// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica
// coi clienti.
export const GET: APIRoute = async ({ params }) => {
// "tutti" segue la stessa regola della pagina: gli orari scaduti non ci sono. Un PDF che si
// porta dietro il periodo finito farebbe cercare lezioni che non esistono più.
const cals = params.id === 'tutti'
? calendariDaMostrare(new Date()).map((m) => m.cal)
: calendari.filter((c) => c.id === params.id);
// Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa
// credere che il calendario sia vuoto.
if (cals.length === 0) return new Response('Calendario inesistente.', { status: 404 });
// Le giornate fuori griglia stanno anche nel PDF: è il file che gira su WhatsApp, e chi lo
// riceve non ha davanti la pagina dove sono scritte.
const pdf = await generaCalendariPdf(cals, avvisiAttivi(new Date()));
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
// scheda e su telefono resterebbe lì, senza finire fra i file.
'Content-Disposition': `attachment; filename="${nomeFilePdf(cals)}"`,
'Content-Length': String(pdf.length),
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
'Cache-Control': 'public, max-age=3600',
},
});
};
+18 -9
View File
@@ -6,15 +6,18 @@ import contactHero from '../assets/img/contact-hero.jpg';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
import { t, getImageOverride } from '../lib/content'; import { t, getImageOverride } from '../lib/content';
export const prerender = false; export const prerender = false;
const mapsQuery = encodeURIComponent(`${t('global.contact.address')}, ${t('global.contact.city-line')}`); const mapsQuery = encodeURIComponent(t('global.contact.map-query'));
--- ---
<Base title={t('contact.meta.title')} description={t('contact.meta.description')}> <Base title={t('contact.meta.title')} description={t('contact.meta.description')}>
<PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} /> <PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} />
<section class="section"> <section class="section">
<div class="container cgrid"> <div class="container cgrid">
<div class="cmap" id="map-box"> <div class="cmap" id="map-box" data-maps-query={mapsQuery}>
<button class="btn btn--dark" id="map-load">{t('contact.map.button')}</button> <div class="cmap__consent">
<p class="cmap__note">{t('contact.map.note')}</p> <p class="cmap__title">Mappa Google</p>
<p class="cmap__note">Caricando la mappa accetti che Google riceva i tuoi dati di connessione (incluso l'indirizzo IP) secondo la sua informativa. Vedi la nostra <a href="/cookie-policy">Cookie Policy</a>.</p>
<button class="btn btn--dark" id="map-load" type="button">Carica la mappa</button>
</div>
</div> </div>
<ContactForm variant="full" /> <ContactForm variant="full" />
</div> </div>
@@ -40,18 +43,24 @@ const mapsQuery = encodeURIComponent(`${t('global.contact.address')}, ${t('globa
<style> <style>
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; } .cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; } .cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; align-items: center; justify-content: center; }
.cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; } .cmap iframe { display: block; width: 100%; height: 100%; min-height: 420px; border: 0; }
.cmap__note { font-size: .8rem; color: var(--c-text-light); } .cmap__consent { max-width: 380px; text-align: center; padding: 24px; display: flex; flex-direction: column; gap: 12px; align-items: center; }
.cmap__title { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .12em; font-size: .9rem; margin: 0; }
.cmap__note { font-size: .8rem; color: var(--c-text-light); margin: 0; line-height: 1.5; }
.cmap__note a { color: var(--c-accent-dark); }
.cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; } .cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; } .cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
.cinvite { max-width: 640px; } .cinvite { max-width: 640px; }
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } } @media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
</style> </style>
<script define:vars={{ mapsQuery }}> <script>
// GDPR: la mappa Google (terza parte) viene caricata solo dopo consenso esplicito.
document.getElementById('map-load')?.addEventListener('click', () => { document.getElementById('map-load')?.addEventListener('click', () => {
const box = document.getElementById('map-box'); const box = document.getElementById('map-box');
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`; if (!box) return;
const q = box.dataset.mapsQuery ?? '';
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${q}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade" style="display:block;width:100%;height:100%;min-height:420px;border:0"></iframe>`;
}); });
</script> </script>
+52
View File
@@ -0,0 +1,52 @@
---
import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro';
export const prerender = false;
---
<Base title="Cookie Policy — InsanityLab" description="Informativa sull'uso dei cookie del sito insanitylab.it.">
<PageHero title="Cookie Policy" />
<section class="section">
<div class="container legal">
<p class="legal__updated">Ultimo aggiornamento: luglio 2026</p>
<p>La presente Cookie Policy descrive i cookie e le tecnologie analoghe utilizzati dal sito <strong>insanitylab.it</strong>, gestito da <strong>IN-SANITY LAB S.R.L.</strong> (Via Leandro Alberti 76, 40139 Bologna — P. IVA 03927341200).</p>
<h2>1. Cosa sono i cookie</h2>
<p>I cookie sono piccoli file di testo che i siti visitati inviano al dispositivo dell'utente, dove vengono memorizzati per essere ritrasmessi agli stessi siti alla visita successiva. Possono avere finalità tecniche, funzionali o di profilazione.</p>
<h2>2. Cookie utilizzati da questo sito</h2>
<p>Il sito utilizza <strong>esclusivamente cookie tecnici</strong>, necessari al funzionamento dell'area riservata. Questi cookie non richiedono il consenso dell'utente e non vengono impostati ai visitatori non autenticati.</p>
<div class="legal__table">
<table>
<thead><tr><th>Cookie</th><th>Finalità</th><th>Durata</th><th>Tipo</th></tr></thead>
<tbody>
<tr><td>session</td><td>Mantiene l'autenticazione dell'utente nell'area riservata</td><td>Sessione</td><td>Tecnico</td></tr>
<tr><td>showtags</td><td>Ricorda la preferenza di visualizzazione dei tag di modifica dei contenuti (solo utenti autorizzati)</td><td>Sessione</td><td>Tecnico / funzionale</td></tr>
</tbody>
</table>
</div>
<p>Il sito <strong>non utilizza cookie di profilazione, di marketing o di analisi statistica</strong> (nessun Google Analytics o strumento equivalente).</p>
<h2>3. Contenuti di terze parti — Google Maps</h2>
<p>La pagina Contatti può mostrare una mappa fornita da <strong>Google Maps</strong>. Per tutelare la tua privacy, la mappa <strong>non viene caricata automaticamente</strong>: compare solo dopo che hai cliccato l'apposito pulsante di consenso. Fino a quel momento nessun dato viene inviato a Google. Se scegli di caricare la mappa, Google può impostare propri cookie e trattare dati (incluso l'indirizzo IP) secondo la propria <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">informativa privacy</a>.</p>
<h2>4. Gestione dei cookie dal browser</h2>
<p>L'utente può gestire o eliminare i cookie tramite le impostazioni del proprio browser. La disabilitazione dei cookie tecnici può compromettere il funzionamento dell'area riservata. Le istruzioni sono disponibili nelle guide dei principali browser (Chrome, Firefox, Safari, Edge).</p>
<h2>5. Titolare e contatti</h2>
<p>Per qualsiasi richiesta relativa ai cookie e al trattamento dei dati è possibile scrivere a <a href="mailto:info@insanitylab.it">info@insanitylab.it</a>. Per maggiori informazioni sul trattamento dei dati personali si rimanda alla <a href="/privacy">Privacy Policy</a>.</p>
</div>
</section>
</Base>
<style>
.legal { max-width: 760px; }
.legal__updated { font-size: .8rem; color: var(--c-text-light); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 30px; }
.legal h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: .08em; margin: 34px 0 12px; }
.legal p, .legal li { line-height: 1.7; }
.legal a { color: var(--c-accent-dark); }
.legal__table { overflow-x: auto; margin: 12px 0; }
.legal table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.legal th, .legal td { border: 1px solid var(--c-border, #e4ded6); padding: 10px 12px; text-align: left; vertical-align: top; }
.legal th { background: var(--c-bg-alt); text-transform: uppercase; font-size: .75rem; letter-spacing: .06em; }
</style>
-4
View File
@@ -6,8 +6,6 @@ import FeatureBlocks from '../components/home/FeatureBlocks.astro';
import QuoteBanner from '../components/home/QuoteBanner.astro'; import QuoteBanner from '../components/home/QuoteBanner.astro';
import TrainingCards from '../components/home/TrainingCards.astro'; import TrainingCards from '../components/home/TrainingCards.astro';
import ServicesGrid from '../components/home/ServicesGrid.astro'; import ServicesGrid from '../components/home/ServicesGrid.astro';
import Agenda from '../components/Agenda.astro';
import TeamSection from '../components/home/TeamSection.astro';
import MethodSteps from '../components/home/MethodSteps.astro'; import MethodSteps from '../components/home/MethodSteps.astro';
import PartnersStrip from '../components/home/PartnersStrip.astro'; import PartnersStrip from '../components/home/PartnersStrip.astro';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
@@ -27,8 +25,6 @@ export const prerender = false;
</section> </section>
<TrainingCards /> <TrainingCards />
<ServicesGrid /> <ServicesGrid />
<Agenda />
<TeamSection />
<MethodSteps /> <MethodSteps />
<PartnersStrip /> <PartnersStrip />
</Base> </Base>

Some files were not shown because too many files have changed in this diff Show More