Compare commits

..

26 Commits

Author SHA1 Message Date
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 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 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
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 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
29 changed files with 1038 additions and 54 deletions
+2
View File
@@ -7,3 +7,5 @@ data/*.db*
uploads/*
!uploads/.gitkeep
assets-src/
__pycache__/
*.pyc
+6 -1
View File
@@ -23,7 +23,12 @@ services:
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`)
# ⚠️ `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
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.
+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()
+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 — unanalisi 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: 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

+20 -1
View File
@@ -173,7 +173,23 @@ const disciplinePresenti = (cal: Calendario) =>
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
.cal-tab { border-collapse: collapse; width: 100%; min-width: 720px; }
/* `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);
@@ -184,6 +200,9 @@ const disciplinePresenti = (cal: Calendario) =>
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);
+5 -1
View File
@@ -4,6 +4,10 @@ import { trainings } from '../../data/trainings';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
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 imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
const tones = ['taupe', 'tan', 'dark', 'charcoal'];
@@ -11,7 +15,7 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
---
<section class="tcards">
{trainings.map((tr, i) => {
{items.map((tr, i) => {
const img = imgOf(tr.image);
const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
+26 -5
View File
@@ -47,13 +47,20 @@ export const calendari: Calendario[] = [
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', VEN: 'MOBILITY' } },
{ ora: '9.15', celle: { VEN: 'REFORMER', SAB: 'MOBILITY' } },
{ ora: '10.00', celle: { VEN: 'REFORMER', SAB: 'PILATES' } },
{ 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' } },
@@ -146,8 +153,22 @@ export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): Avvi
.sort((x, y) => Date.parse(x.to) - Date.parse(y.to));
}
/** Servizi dalla cui pagina si apre il calendario della sala. */
export const SERVIZI_CON_CALENDARIO = ['performance-class', 'holistic-class'];
/**
* 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;
+39 -8
View File
@@ -165,7 +165,7 @@ export const contentSeed: SeedEntry[] = [
// --- About: meta, hero ---
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.hero.title', 'Insanitylab', []),
T('about.hero.title', 'About us', []),
// --- About: mission ---
T('about.mission.title', 'Mission'),
@@ -222,7 +222,7 @@ export const contentSeed: SeedEntry[] = [
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.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.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
@@ -231,7 +231,7 @@ export const contentSeed: SeedEntry[] = [
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
// --- Services: lista (/services) ---
T('services.meta.title', 'Servizi', []),
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia 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.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ù →', []),
@@ -247,6 +247,13 @@ export const contentSeed: SeedEntry[] = [
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
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`),
]),
@@ -277,8 +284,9 @@ export const contentSeed: SeedEntry[] = [
// --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []),
T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: 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.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.'),
// --- Contact ---
@@ -293,6 +301,29 @@ export const contentSeed: SeedEntry[] = [
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.'),
// --- 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 dallinaugurazione 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à loccasione 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 ---
T('notfound.meta.title', 'Pagina non trovata', []),
T('notfound.eyebrow', 'Errore 404', []),
@@ -327,10 +358,10 @@ export const contentSeed: SeedEntry[] = [
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 — Small Group (in evidenza)
// 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', 'Small Group'),
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', []),
@@ -373,12 +404,12 @@ export const contentSeed: SeedEntry[] = [
// 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 Small Group?'),
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">Small 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.'),
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?'),
+39 -9
View File
@@ -13,12 +13,24 @@ export interface Service {
/** 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[] = [
{
slug: "one-to-one",
title: "One to One",
// La card di /services porta alla vista Training con One to One, Personalized Training
// Group e Rehab: è come il cliente vuole instradare chi cerca l'allenamento seguito.
href: "/training/one-to-one",
heroTitle: "Personal Training",
subtitle: "Allenamento con un trainer dedicato",
excerpt: "Lallenamento 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.",
@@ -33,13 +45,13 @@ export const services: Service[] = [
},
{
slug: "small-group",
title: "Small Group",
title: "Personalized Training Group",
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
excerpt: "Lo Small 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. Unesperienza small 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.",
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. Unesperienza 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 Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
{ 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." },
],
@@ -47,8 +59,14 @@ export const services: Service[] = [
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",
title: "Class",
href: "/training/class",
page: false,
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.",
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 allinterno di un piano più ampio.",
@@ -61,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: "Qual è lintensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità allinterno della stessa sessione, adattate al livello dei partecipanti." },
],
hasPricing: true,
hasPricing: false,
},
{
slug: "coaching",
@@ -81,16 +99,28 @@ export const services: Service[] = [
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
slug: "profilazione",
title: "Profilazione",
subtitle: "Check-up e valutazione fisica",
excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.",
title: "Check-up",
subtitle: "Base e avanzato",
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 — unanalisi 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: [
{ q: "Quando si fa la profilazione?", a: "Allinizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
{ q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." },
],
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 lHRV 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",
],
},
},
{
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
+4 -4
View File
@@ -21,17 +21,17 @@ export const site = {
],
footerTraining: [
{ label: "One to one", href: "/training#one-to-one" },
{ label: "Small group", href: "/training#small-groups" },
{ label: "Personalized Training Group", href: "/training#small-groups" },
{ label: "Rehab", href: "/training#rehab" },
{ label: "Holistic class", href: "/training#holistic-class" },
{ label: "Reformer class", href: "/training#reformer-class" },
{ label: "Coaching", href: "/training#coaching" },
],
footerServices: [
{ label: "Personal Training", href: "/services/personal-training" },
{ label: "Class", href: "/services/performance-class" },
{ label: "Personal Training", href: "/training/one-to-one" },
{ label: "Class", href: "/training/class" },
{ label: "Coaching", href: "/services/coaching" },
{ label: "Profilazione", href: "/services/profilazione" },
{ label: "Check-up", href: "/services/profilazione" },
{ label: "Osteopatia", href: "/services/osteopatia" },
{ label: "Nutrizione", href: "/services/nutrition" },
],
+28 -1
View File
@@ -1,8 +1,35 @@
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: "Scopri", href: "/services/one-to-one" } },
{ id: "small-groups", title: "Small 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. Unesperienza small 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: "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. Unesperienza 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: "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: "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;
/**
* 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);
}
+288
View File
@@ -0,0 +1,288 @@
---
import Base from '../../layouts/Base.astro';
export const prerender = false;
// La landing dell'inaugurazione del 19 settembre. Il blocco del titolo è il ritaglio
// della locandina vera (font e calligrafia originali, non riprodotti): il resto della
// pagina usa la tipografia del sito e i colori campionati dalla locandina stessa.
// L'URL di questa pagina è quello stampato nel QR code, quindi non va cambiato.
const WHATSAPP = '351 753 5977';
const waLink = `https://wa.me/39${WHATSAPP.replace(/\D/g, '')}`;
type Slot = { ora: string; nome?: string };
type Gruppo = { label: string; slots: Slot[] };
const mattino: Gruppo[] = [
{ label: "Corsi Olistici — 20' a slot", slots: [
{ ora: '10:0010:20', nome: 'Mobility' },
{ ora: '10:3010:50', nome: 'Yoga' },
{ ora: '11:0011:20', nome: 'Pilates Mat' },
{ ora: '11:3011:50', nome: 'Reformer' },
{ ora: '12:0012:20', nome: 'Reformer' },
{ ora: '12:3012:50', nome: 'Reformer' },
] },
{ label: "Personal Training — 30' a slot", slots: ['10:30', '11:00', '11:30', '12:00', '12:30'].map((ora) => ({ ora })) },
{ label: "Check-up e Consulenze — 30' a slot", slots: ['10:30', '11:00', '11:30', '12:00', '12:30'].map((ora) => ({ ora })) },
];
const pomeriggio: Gruppo[] = [
{ label: "Corsi Olistici — 20' a slot", slots: [
{ ora: '16:0016:20', nome: 'Reformer' },
{ ora: '16:3016:50', nome: 'Reformer' },
{ ora: '17:0017:20', nome: 'Pilates' },
{ ora: '17:3017:50', nome: 'Mobility' },
] },
{ label: "Personal Training — 30' a slot", slots: ['16:00', '16:30', '17:00', '17:30'].map((ora) => ({ ora })) },
{ label: "Check-up e Consulenze — 30' a slot", slots: ['16:00', '16:30', '17:00', '17:30'].map((ora) => ({ ora })) },
];
---
<Base
title="In-Sanity Hour — 19 settembre 2026"
description="L'inaugurazione di InsanityLab: venerdì 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti."
>
<section class="ev-hero">
<div class="ev-hero__inner" data-reveal>
<img
class="ev-hero__titolo"
src="/img/insanity-hour-titolo.png"
width="580" height="251"
alt="Save the date — In-Sanity Hour"
/>
<p class="ev-hero__giorno">19</p>
<p class="ev-hero__mese">Settembre</p>
<p class="ev-hero__inaug">Inaugurazione</p>
<p class="ev-hero__claim">Un giorno solo per vivere il Lab da vicino.</p>
<p class="ev-hero__gusto">DJ set · Aperitivo</p>
<p class="ev-hero__luogo">Via Pietro di Mattiolo, 5 — Bologna</p>
</div>
</section>
<section class="section ev-intro">
<div class="container" data-reveal>
<p class="ev-intro__testo">
Una giornata aperta a tutti — clienti e non — per scoprire la nuova struttura e conoscere
il nostro staff. Vivi l'esperienza In-Sanity Lab da vicino: mini class, allenamenti,
consulenze e check up avanzato.
</p>
<p class="ev-intro__gratis">Solo per oggi, tutto gratuito</p>
<p class="ev-intro__testo">
L'ingresso e l'aperitivo serale sono liberi, porta chi vuoi. La prenotazione serve solo
per assicurarti il posto nei singoli slot di prova.
</p>
<a class="btn" href={waLink} target="_blank" rel="noopener">Prenotati su WhatsApp</a>
<p class="ev-nota">{WHATSAPP} — rispondiamo entro la giornata</p>
</div>
</section>
<section class="section ev-programma">
<div class="container">
<p class="eyebrow" data-reveal>Programma della giornata</p>
<h2 data-reveal>Come si svolge<br />l'In-Sanity Hour</h2>
<article class="ev-blocco" data-reveal>
<p class="ev-blocco__ora">10:00</p>
<h3>Apertura</h3>
<p class="ev-blocco__desc">
Apertura ufficiale del Lab. Visita libera degli spazi, promozioni dedicate attive
per tutta la giornata.
</p>
</article>
<article class="ev-blocco" data-reveal>
<p class="ev-blocco__ora">10:00 13:00</p>
<h3>Prova i nostri servizi</h3>
{mattino.map((gruppo) => (
<div class="ev-gruppo">
<p class="ev-gruppo__label">{gruppo.label}</p>
<ul class="ev-slots">
{gruppo.slots.map((slot) => (
<li class:list={['ev-slot', { 'ev-slot--corso': slot.nome }]}>
<span class="ev-slot__ora">{slot.ora}</span>
{slot.nome && <span class="ev-slot__nome">{slot.nome}</span>}
</li>
))}
</ul>
</div>
))}
<a class="btn btn--chiaro" href={waLink} target="_blank" rel="noopener">Prenota uno slot</a>
<p class="ev-nota">{WHATSAPP} — scrivici per riservare uno slot del mattino</p>
</article>
<article class="ev-blocco ev-blocco--pausa" data-reveal>
<p class="ev-blocco__ora">13:00 16:00</p>
<h3>Pausa</h3>
</article>
<article class="ev-blocco" data-reveal>
<p class="ev-blocco__ora">16:00 18:00</p>
<h3>Prova i nostri servizi</h3>
{pomeriggio.map((gruppo) => (
<div class="ev-gruppo">
<p class="ev-gruppo__label">{gruppo.label}</p>
<ul class="ev-slots">
{gruppo.slots.map((slot) => (
<li class:list={['ev-slot', { 'ev-slot--corso': slot.nome }]}>
<span class="ev-slot__ora">{slot.ora}</span>
{slot.nome && <span class="ev-slot__nome">{slot.nome}</span>}
</li>
))}
</ul>
</div>
))}
<a class="btn btn--chiaro" href={waLink} target="_blank" rel="noopener">Prenota uno slot</a>
<p class="ev-nota">{WHATSAPP} — scrivici per riservare uno slot del pomeriggio</p>
</article>
<article class="ev-blocco ev-blocco--festa" data-reveal>
<p class="ev-blocco__ora">18:00 22:00</p>
<h3>Si festeggia!</h3>
<p class="ev-blocco__desc">
DJ set e aperitivo per tutti. Brindisi e la nuova sede finalmente aperta a tutti.
Tra un drink e l'altro, la serata avrà anche i suoi momenti a sorpresa.
Promozioni esclusive valide solo durante l'evento.
</p>
</article>
</div>
</section>
<section class="ev-chiusura">
<div class="container" data-reveal>
<blockquote class="ev-citazione">
«Qui non ti alleni per somigliare a nessuno,<br />ti muovi per ricordare chi sei.»
</blockquote>
<p class="ev-chiusura__luogo">Via Pietro di Mattiolo, 5 — 40139, Bologna</p>
<a class="ev-locandina" href="/locandina-insanity-hour.pdf" download>Scarica la locandina</a>
<p class="ev-torna"><a href="/promo-eventi">← Torna a promo ed eventi</a></p>
</div>
</section>
</Base>
<style>
/* Colori campionati dalla locandina: il marrone e il beige sono quelli veri, non quelli
del sito, così la pagina e il volantino stampato si riconoscono come la stessa cosa. */
.ev-hero, .ev-intro, .ev-programma, .ev-chiusura {
--ev-brown: #4f3126;
--ev-brown-deep: #3d251c;
--ev-beige: #d0b49e;
--ev-cream: #f6f1e9;
--ev-line: rgba(208, 180, 158, .35);
}
/* --- Hero: il ritaglio della locandina su fondo marrone identico, così l'immagine
non ha bordi visibili e sembra composta con il resto della pagina. --- */
.ev-hero {
background: radial-gradient(circle at 50% -10%, #5c3a2d 0%, var(--ev-brown) 55%, var(--ev-brown-deep) 100%);
color: var(--ev-cream);
padding: 64px 24px 72px;
text-align: center;
position: relative;
}
.ev-hero::before {
content: ''; position: absolute; inset: 14px;
border: 1px solid var(--ev-line); pointer-events: none;
}
.ev-hero__inner { max-width: 640px; margin: 0 auto; position: relative; }
.ev-hero__titolo { width: min(100%, 540px); margin: 0 auto; mix-blend-mode: lighten; }
.ev-hero__giorno {
font-family: var(--font-heading); font-size: clamp(3.4rem, 11vw, 4.6rem);
font-weight: 300; line-height: 1; letter-spacing: .04em; margin: 26px 0 0;
}
.ev-hero__mese {
font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
letter-spacing: .35em; text-transform: uppercase; color: var(--ev-beige); margin: 10px 0 0;
}
.ev-hero__inaug {
display: inline-block; margin: 20px 0 0; padding: 7px 16px;
border: 1px solid var(--ev-line);
font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
letter-spacing: .24em; text-transform: uppercase; color: var(--ev-cream);
}
.ev-hero__claim { margin: 26px 0 0; font-size: 1.05rem; line-height: 1.7; color: rgba(246, 241, 233, .9); }
.ev-hero__gusto {
margin: 18px 0 0; font-family: var(--font-heading); font-size: .7rem; font-weight: 600;
letter-spacing: .28em; text-transform: uppercase; color: var(--ev-beige);
}
.ev-hero__luogo {
margin: 26px 0 0; font-family: var(--font-heading); font-size: .68rem;
letter-spacing: .18em; text-transform: uppercase; color: rgba(246, 241, 233, .6);
}
.ev-locandina {
display: inline-block; margin-top: 34px; padding: 12px 22px;
border: 1px solid var(--ev-beige); color: var(--ev-beige); text-decoration: none;
font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
letter-spacing: .2em; text-transform: uppercase; transition: background .2s, color .2s;
}
.ev-locandina:hover { background: var(--ev-beige); color: var(--ev-brown); }
/* --- Intro --- */
.ev-intro .container { max-width: 720px; text-align: center; }
.ev-intro__testo { margin: 0 0 18px; }
.ev-intro__gratis {
font-family: var(--font-heading); font-size: clamp(1.15rem, 2.6vw, 1.5rem);
font-weight: 600; letter-spacing: .06em; color: var(--ev-brown); margin: 26px 0;
}
.ev-nota {
margin: 14px 0 0; font-size: .84rem; color: var(--c-text-light);
}
/* --- Programma --- */
.ev-programma { background: var(--c-bg-alt); }
.ev-programma .container { max-width: 800px; }
.ev-programma .eyebrow, .ev-programma h2 { text-align: center; }
.ev-programma h2 { margin-bottom: 46px; }
.ev-blocco {
background: #fff; border-left: 3px solid var(--ev-beige);
padding: 30px 30px 34px; margin-bottom: 20px; text-align: center;
}
.ev-blocco__ora {
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
letter-spacing: .2em; color: var(--ev-brown); margin: 0 0 8px;
}
.ev-blocco h3 { text-transform: uppercase; letter-spacing: .08em; margin: 0 0 12px; }
.ev-blocco__desc { margin: 0; }
.ev-blocco--pausa { border-left-color: var(--c-text-light); opacity: .75; }
.ev-blocco--festa { border-left-color: var(--ev-brown); }
.ev-gruppo { margin-bottom: 24px; }
.ev-gruppo__label {
font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light);
margin: 0 0 12px;
}
.ev-slots {
list-style: none; margin: 0; padding: 0;
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
}
.ev-slot {
border: 1px solid var(--ev-line); padding: 8px 14px;
font-family: var(--font-heading); font-size: .78rem; color: var(--ev-brown);
}
.ev-slot--corso { display: flex; flex-direction: column; gap: 2px; }
.ev-slot__nome { font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-text-light); }
.btn--chiaro { margin-top: 10px; }
/* --- Chiusura --- */
.ev-chiusura {
background: var(--ev-brown); color: var(--ev-cream);
padding: 64px 24px; text-align: center;
}
.ev-citazione {
margin: 0; font-size: clamp(1.35rem, 3.4vw, 1.9rem); line-height: 1.5;
font-style: italic; color: var(--ev-cream);
}
.ev-chiusura__luogo {
margin: 30px 0 0; font-family: var(--font-heading); font-size: .68rem;
letter-spacing: .18em; text-transform: uppercase; color: var(--ev-beige);
}
.ev-torna { margin: 34px 0 0; }
.ev-torna a {
font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em;
text-transform: uppercase; color: rgba(246, 241, 233, .7); text-decoration: none;
}
.ev-torna a:hover { color: var(--ev-beige); }
@media (max-width: 767px) {
.ev-hero { padding: 44px 18px 54px; }
.ev-blocco { padding: 24px 18px 28px; }
}
</style>
+56
View File
@@ -0,0 +1,56 @@
---
import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro';
import T from '../../components/content/T.astro';
import { t } from '../../lib/content';
import { edizioneAttiva } from '../../data/promo-edizioni';
export const prerender = false;
// Chi entra da «Promo ed eventi» sceglie: le promozioni in corso oppure gli eventi del
// centro (chiesto dal cliente il 17/08/2026). Erano la stessa pagina, e chi cercava
// l'inaugurazione finiva in mezzo ai prezzi dei pacchetti.
//
// Il bottone delle promo dice se ce n'è davvero una in corso: mandare su una pagina che
// risponde «nessuna promozione» senza averlo detto prima è un giro a vuoto.
const ed = edizioneAttiva(new Date());
// La pagina cambia contenuto col passare del tempo (l'edizione promo scade da sola): una
// copia in cache la congelerebbe a com'era.
Astro.response.headers.set('Cache-Control', 'no-store');
---
<Base title={t('promo-eventi.meta.title')} description={t('promo-eventi.meta.description')}>
<PageHero title={t('promo-eventi.hero.title')} />
<section class="section">
<div class="container">
<div class="section-heading">
<T tag="promo-eventi.intro" as="p" />
</div>
<div class="scelta">
<a class="scelta__card" href="/promo-eventi/promo">
<T tag="promo-eventi.promo.label" as="h2" />
<T tag="promo-eventi.promo.desc" as="p" />
{!ed && <T tag="promo-eventi.promo.nessuna" as="p" class="scelta__nota" />}
<span class="btn">{t('promo-eventi.promo.cta')}</span>
</a>
<a class="scelta__card scelta__card--dark" href="/promo-eventi/eventi">
<T tag="promo-eventi.eventi.label" as="h2" />
<T tag="promo-eventi.eventi.desc" as="p" />
<span class="btn btn--light">{t('promo-eventi.eventi.cta')}</span>
</a>
</div>
</div>
</section>
</Base>
<style>
.scelta { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
.scelta__card { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; padding: 48px 40px; background: #fff; box-shadow: 0 6px 24px rgba(0,0,0,.08); text-decoration: none; transition: transform .3s ease, box-shadow .3s ease; }
.scelta__card:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(0,0,0,.12); }
.scelta__card :global(h2) { text-transform: uppercase; letter-spacing: .12em; font-size: 1.4rem; margin: 0; }
.scelta__card :global(p) { font-size: .92rem; margin: 0; }
.scelta__card .btn { margin-top: auto; }
.scelta__card--dark { background: var(--c-dark); }
.scelta__card--dark :global(h2), .scelta__card--dark :global(p) { color: #efe9e3; }
.scelta__nota { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); }
@media (max-width: 767px) { .scelta { grid-template-columns: 1fr; } .scelta__card { padding: 34px 26px; } }
</style>
@@ -1,10 +1,10 @@
---
import Base from '../layouts/Base.astro';
import T from '../components/content/T.astro';
import { t, resolveContent } from '../lib/content';
import { edizioneAttiva, tagEdizione } from '../data/promo-edizioni';
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
import { getDb } from '../lib/db';
import Base from '../../layouts/Base.astro';
import T from '../../components/content/T.astro';
import { t, resolveContent } from '../../lib/content';
import { edizioneAttiva, tagEdizione } from '../../data/promo-edizioni';
import { getSessionUser, SESSION_COOKIE } from '../../lib/auth';
import { getDb } from '../../lib/db';
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account e sceglie lì
// dentro il prodotto, poi paga via Stripe. Il link è uno solo per tutte le card, ed è voluto:
+17 -4
View File
@@ -8,18 +8,20 @@ import { serviceIcons } from '../../data/service-icons';
import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro';
import CalendarioModal from '../../components/CalendarioModal.astro';
import { calendariDaMostrare, SERVIZI_CON_CALENDARIO } from '../../data/calendari';
import { calendariDaMostrare, PAGINE_CON_CALENDARIO } from '../../data/calendari';
import { t } from '../../lib/content';
export const prerender = false;
const service = getService(Astro.params.slug!);
if (!service) return new Response(null, { status: 404 });
// `page: false` = servizio senza scheda (oggi la Performance Class, che non si eroga più):
// la voce esiste ancora nei dati per la card in griglia, ma la pagina non c'è.
if (!service || service.page === false) return new Response(null, { status: 404 });
const slug = service.slug;
// Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è
// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è
// peggio di nessun link.
const conCalendario = SERVIZI_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0;
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false);
const conCalendario = PAGINE_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0;
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false && p.page !== false);
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
const heroImg = imgOf(service.slug);
@@ -44,6 +46,15 @@ const tones = ['tan', 'taupe', 'dark'];
{heroImg && (
<TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
)}
{service.extra && (
<div class="prog__extra">
<T tag={`service.${slug}.extra.title`} as="h2" />
<T tag={`service.${slug}.extra.body`} as="p" class="prog__desc" />
<ul class="prog__features">
{service.extra.items.map((_, i) => <T tag={`service.${slug}.extra.item.${i + 1}`} as="li" />)}
</ul>
</div>
)}
<div class="prog__faq">
{service.faq.map((_, i) => (
<details open={i === 0}>
@@ -119,6 +130,8 @@ const tones = ['tan', 'taupe', 'dark'];
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
.prog :global(.prog__desc) { white-space: pre-line; }
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
.prog__extra { padding-top: 30px; margin-bottom: 10px; }
.prog__extra h2 { letter-spacing: .1em; margin-bottom: .5em; }
.prog__features li::marker { color: var(--c-accent); }
.prog__img { width: 100%; height: auto; margin-bottom: 30px; }
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
+22 -4
View File
@@ -5,6 +5,8 @@ import type { ImageMetadata } from 'astro';
import { services } from '../../data/services';
import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro';
import CalendarioModal from '../../components/CalendarioModal.astro';
import { calendariDaMostrare, CARD_CON_CALENDARIO } from '../../data/calendari';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
@@ -13,7 +15,13 @@ const gridServices = services.filter((p) => p.grid !== false);
// costruzione: erano due espressioni diverse e sono divergute — su One to One e Performance
// Class il testo mandava a /training mentre la foto apriva la scheda del servizio, così la
// destinazione dipendeva da dove capitava il dito.
const hrefOf = (slug: string) => `/services/${slug}`;
// Alcune card non portano alla propria scheda ma a una vista di /training (One to One e
// Class, deciso dal cliente il 17/08/2026): la destinazione la dichiara il servizio.
const hrefOf = (p: (typeof services)[number]) => p.href ?? `/services/${p.slug}`;
// Il calendario delle class si apre anche da qui, dalla card Class: chi guarda i servizi
// vuole sapere quando si allena prima ancora di aprire la scheda. Compare solo se c'è
// davvero una griglia da mostrare, come nelle pagine delle class.
const conCalendario = calendariDaMostrare(new Date()).length > 0;
export const prerender = false;
---
<Base title={t('services.meta.title')} description={t('services.meta.description')}>
@@ -27,7 +35,7 @@ export const prerender = false;
{gridServices.map((p, i) => (
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
{imgOf(p.slug) && (
<a class="plist__photo" href={hrefOf(p.slug)}>
<a class="plist__photo" href={hrefOf(p)}>
<TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
</a>
)}
@@ -35,13 +43,17 @@ export const prerender = false;
<T tag={`service.${p.slug}.title`} as="h3" />
<T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
<T tag={`service.${p.slug}.excerpt`} as="p" />
<a class="plist__more" href={hrefOf(p.slug)}>{t('services.card.more-label')}</a>
{conCalendario && CARD_CON_CALENDARIO.includes(p.slug) && (
<button type="button" class="plist__calendario" data-apri-calendario>{t('calendario.link-label')}</button>
)}
<a class="plist__more" href={hrefOf(p)}>{t('services.card.more-label')}</a>
</div>
</article>
))}
</div>
</div>
</section>
{conCalendario && <CalendarioModal />}
</Base>
<style>
@@ -53,9 +65,15 @@ export const prerender = false;
.plist__body { padding: 26px 24px 30px; display: flex; flex-direction: column; align-items: flex-start; flex: 1; }
.plist__card h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .3em; }
.plist__sub { font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
.plist__body > p:not(.plist__sub) { font-size: .9rem; }
.plist__body > p:not(.plist__sub) { font-size: .9rem; white-space: pre-line; }
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); margin-top: auto; }
.plist__more:hover { color: var(--c-accent-dark); }
/* Il calendario sta sopra "Per saperne di più" (posizione chiesta dal cliente) e apre un
popup, quindi è un bottone: qui gli si toglie l'aspetto da bottone perché nella card
stia accanto all'altra voce senza rubarle l'occhio. */
.plist__calendario { background: none; border: 0; padding: 0; margin-top: auto; cursor: pointer; text-align: left; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent-dark); }
.plist__calendario:hover { color: var(--c-heading); }
.plist__calendario + .plist__more { margin-top: 10px; }
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
</style>
@@ -1,28 +1,37 @@
---
import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro';
import TrainingCards from '../components/home/TrainingCards.astro';
import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro';
import TrainingCards from '../../components/home/TrainingCards.astro';
import { Image } from 'astro:assets';
import { trainings } from '../data/trainings';
import T from '../components/content/T.astro';
import { t } from '../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
import type { ImageMetadata } from 'astro';
import { VISTE_TRAINING, trainingDaMostrare } from '../../data/trainings';
import T from '../../components/content/T.astro';
import { t } from '../../lib/content';
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;
export const prerender = false;
// `/training` mostra tutto; `/training/one-to-one` e `/training/class` il solo sottoinsieme
// da cui si arriva cliccando la card corrispondente in /services. Un indirizzo inventato è
// 404 e non l'elenco intero: un filtro che non c'è deve vedersi, non passare per buono.
const vista = Astro.params.vista;
const daMostrare = trainingDaMostrare(vista);
if (!daMostrare) return new Response(null, { status: 404 });
const filtrata = Boolean(vista);
---
<Base title={t('training.meta.title')} description={t('training.meta.description')}>
<PageHero title={t('training.hero.title')} />
<section class="section" style="padding-bottom:0">
<div class="section-heading">
<T tag="training.intro.subtitle" as="p" />
{filtrata ? <p>{VISTE_TRAINING[vista!].titolo}</p> : <T tag="training.intro.subtitle" as="p" />}
</div>
</section>
<TrainingCards />
<TrainingCards items={daMostrare} />
<section class="section">
<div class="container">
{trainings.map((tr, i) => (
{daMostrare.map((tr, i) => (
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
@@ -32,6 +41,11 @@ export const prerender = false;
</div>
</div>
))}
{/* Da una vista filtrata si esce: senza questo link chi è entrato da una card di
/services non ha modo di vedere gli altri training se non dal menu. */}
{filtrata && (
<p class="tblock__tutti"><a class="btn btn--light" href="/training">{t('training.tutti.cta-label')}</a></p>
)}
</div>
</section>
</Base>
@@ -40,5 +54,6 @@ export const prerender = false;
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
.tblock--reverse img { order: 2; }
.tblock h2 { letter-spacing: .1em; }
.tblock__tutti { text-align: center; margin-top: 20px; }
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
</style>
+31
View File
@@ -0,0 +1,31 @@
import { existsSync, readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
// L'indirizzo della pagina evento e' stampato dentro il QR code delle locandine affisse
// in giro per Bologna: una volta in strada non si corregge piu'. Questi test tengono
// insieme le tre cose che devono restare d'accordo — la rotta servita dal sito, l'URL
// cucito nel QR e il file che la pagina offre in download.
const ROTTA = 'src/pages/promo-eventi/eventi.astro';
const URL_STAMPATO = 'https://insanitylab.it/promo-eventi/eventi';
describe('locandina In-Sanity Hour', () => {
it('la rotta che il QR indica esiste', () => {
expect(existsSync(ROTTA)).toBe(true);
});
it('il generatore della locandina punta a quella rotta', () => {
const script = readFileSync('scripts/locandina-evento.py', 'utf-8');
expect(script).toContain(`URL_EVENTO = '${URL_STAMPATO}'`);
// La rotta del file deve corrispondere alla coda dell'URL, altrimenti il QR
// porterebbe altrove: /promo-eventi/eventi <- src/pages/promo-eventi/eventi.astro
const daFile = '/' + ROTTA.replace('src/pages/', '').replace('.astro', '');
expect(URL_STAMPATO.endsWith(daFile)).toBe(true);
});
it('la locandina che la pagina offre in download esiste', () => {
const pagina = readFileSync(ROTTA, 'utf-8');
const link = pagina.match(/href="(\/locandina-[^"]+\.pdf)"/);
expect(link).not.toBeNull();
expect(existsSync(`public${link![1]}`)).toBe(true);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest';
import { createDb } from '../src/lib/db';
import { services, getService } from '../src/data/services';
import { trainings, trainingDaMostrare, VISTE_TRAINING } from '../src/data/trainings';
import { contentSeed } from '../src/data/content-seed';
import { site } from '../src/data/site';
import { PAGINE_CON_CALENDARIO, CARD_CON_CALENDARIO } from '../src/data/calendari';
import { rinominaSmallGroup, calcola, applica, TESTI } from '../scripts/testi-17-agosto.mjs';
// Le modifiche chieste dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
describe('Small Group è diventato Personalized Training Group', () => {
it('non resta traccia del vecchio nome nei dati né nel seed', () => {
const testi = [
...services.flatMap((s) => [s.title, s.subtitle, s.excerpt, s.longDescription, ...s.features, ...s.faq.flatMap((f) => [f.q, f.a])]),
...trainings.flatMap((t) => [t.title, t.caption, t.description]),
...site.footerTraining.map((v) => v.label),
...contentSeed.map((e) => e.value),
];
expect(testi.filter((v) => /small group/i.test(v))).toEqual([]);
});
// Lo slug resta `small-group`: è l'indirizzo della scheda, e cambiarlo romperebbe i link
// già girati su WhatsApp senza che nessuno se ne accorga.
it('lo slug della scheda non cambia', () => {
expect(getService('small-group')?.title).toBe('Personalized Training Group');
});
it('gli articoli restano corretti, e passarci due volte non cambia niente', () => {
const casi: [string, string][] = [
['Lo Small Group di InsanityLab è un allenamento', 'Il Personalized Training Group di InsanityLab è un allenamento'],
['nuovi format come gli small group e una', 'nuovi format come i Personalized Training Group e una'],
['One to One / Small Group / Rehab', 'One to One / Personalized Training Group / Rehab'],
['Small Groups', 'Personalized Training Group'],
['in formula small group (massimo 4 persone)', 'in formula Personalized Training Group (massimo 4 persone)'],
];
for (const [prima, dopo] of casi) {
expect(rinominaSmallGroup(prima)).toBe(dopo);
expect(rinominaSmallGroup(dopo)).toBe(dopo);
}
});
});
describe('lo script porta i testi nuovi in un database già scritto', () => {
it('aggiorna le righe vecchie e al secondo giro non ha più niente da fare', () => {
const db = createDb(':memory:');
const scrivi = db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
scrivi.run('service.one-to-one.subtitle', 'text', 'One to One / Small Group / Rehab');
scrivi.run('service.profilazione.subtitle', 'text', 'Profilazione e valutazione fisica');
scrivi.run('footer.services.4.label', 'text', 'Profilazione');
const cambi = applica(db);
const dopo = (tag: string) => (db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(dopo('service.one-to-one.subtitle')).toBe('One to One / Personalized Training Group / Rehab');
expect(dopo('service.profilazione.subtitle')).toBe('Base e avanzato');
expect(dopo('footer.services.4.label')).toBe('Check-up');
expect(cambi.length).toBeGreaterThanOrEqual(3);
expect(calcola(db)).toEqual([]);
});
// Un `--dry-run` che scrivesse renderebbe inutile guardare prima di toccare la produzione.
it('con --dry-run non tocca niente', () => {
const db = createDb(':memory:');
db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)')
.run('promo.card.2.title', 'text', 'Small Group');
expect(applica(db, { dryRun: true }).length).toBe(1);
expect((db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('promo.card.2.title') as { value: string }).value)
.toBe('Small Group');
});
it('i testi riscritti a mano corrispondono a quelli del seed', () => {
const seed = new Map(contentSeed.map((e) => [e.tag, e.value]));
for (const [tag, valore] of Object.entries(TESTI)) expect(seed.get(tag)).toBe(valore);
});
});
describe('viste di /training', () => {
it('senza vista si vedono tutti i training', () => {
expect(trainingDaMostrare()?.map((t) => t.id)).toEqual(trainings.map((t) => t.id));
});
it('One to One porta a One to One, Personalized Training Group e Rehab', () => {
expect(trainingDaMostrare('one-to-one')?.map((t) => t.id)).toEqual(['one-to-one', 'small-groups', 'rehab']);
});
it('Class porta a Holistic e Reformer', () => {
expect(trainingDaMostrare('class')?.map((t) => t.id)).toEqual(['holistic-class', 'reformer-class']);
});
// Un filtro sbagliato che mostra tutto farebbe passare per buono un indirizzo rotto.
it('una vista inventata è 404, non l\'elenco intero', () => {
expect(trainingDaMostrare('inesistente')).toBeNull();
});
it('ogni vista nomina solo training che esistono', () => {
for (const v of Object.values(VISTE_TRAINING)) {
for (const id of v.blocchi) expect(trainings.some((t) => t.id === id)).toBe(true);
}
});
});
describe('instradamento delle card di /services', () => {
const href = (slug: string) => { const s = getService(slug)!; return s.href ?? `/services/${s.slug}`; };
it('One to One e Class portano alle viste di training', () => {
expect(href('one-to-one')).toBe('/training/one-to-one');
expect(href('performance-class')).toBe('/training/class');
});
it('Check-up porta alla propria scheda', () => {
expect(href('profilazione')).toBe('/services/profilazione');
});
// La Performance Class non si eroga più: la card resta, la scheda che la spiegava no.
it('la Performance Class non ha più una scheda ma resta in griglia', () => {
expect(getService('performance-class')?.page).toBe(false);
expect(getService('performance-class')?.grid).not.toBe(false);
});
it('nessun link del sito manda a una scheda che non esiste', () => {
const schede = new Set(services.filter((s) => s.page !== false).map((s) => `/services/${s.slug}`));
const interni = [...site.footerServices, ...site.footerTraining, ...site.navigation]
.map((v) => v.href)
.filter((h) => h.startsWith('/services/'));
for (const h of interni) expect(schede.has(h)).toBe(true);
});
});
describe('Check-up', () => {
const checkup = getService('profilazione')!;
it('il trafiletto della card racconta Base e Avanzato', () => {
expect(checkup.title).toBe('Check-up');
expect(checkup.subtitle).toBe('Base e avanzato');
expect(checkup.excerpt).toContain('Check-up Base');
expect(checkup.excerpt).toContain('Check-up Avanzato');
});
it('la scheda porta la sezione avanzata con i suoi cinque punti', () => {
expect(checkup.extra?.title).toBe('Check-up Avanzato');
expect(checkup.extra?.body).toContain('Wellness Tower');
expect(checkup.extra?.items).toHaveLength(5);
});
// Tolta il 19/08 su richiesta del cliente: restava in fondo alla scheda e diceva cose che
// la sezione Avanzato racconta meglio.
it('la domanda «Che cosa comprende?» non c\'è più', () => {
expect(checkup.faq).toHaveLength(1);
expect(checkup.faq.map((f) => f.q)).not.toContain('Che cosa comprende?');
});
it('i testi della sezione avanzata sono modificabili dal pannello', () => {
const tags = new Set(contentSeed.map((e) => e.tag));
expect(tags.has('service.profilazione.extra.title')).toBe(true);
expect(tags.has('service.profilazione.extra.body')).toBe(true);
for (let i = 1; i <= 5; i++) expect(tags.has(`service.profilazione.extra.item.${i}`)).toBe(true);
});
});
describe('calendario delle class', () => {
// Il calendario si apre da due punti diversi: le schede delle class che si fanno, e la
// card «Class» della griglia servizi, che ha slug `performance-class`.
it('le schede sono Holistic e Reformer, la Performance Class è fuori', () => {
expect(PAGINE_CON_CALENDARIO).toEqual(['holistic-class', 'reformer-class']);
});
it('in griglia il bottone sta sulla card Class', () => {
expect(CARD_CON_CALENDARIO).toEqual(['performance-class']);
});
it('ogni slug nominato esiste davvero', () => {
for (const slug of [...PAGINE_CON_CALENDARIO, ...CARD_CON_CALENDARIO]) expect(getService(slug)).toBeDefined();
});
});
describe('promo ed eventi', () => {
it('i testi della pagina di scelta e degli eventi sono nel seed', () => {
const tags = new Set(contentSeed.map((e) => e.tag));
for (const tag of ['promo-eventi.hero.title', 'promo-eventi.promo.label', 'promo-eventi.eventi.label',
'eventi.hero.title', 'eventi.inaugurazione.title', 'eventi.inaugurazione.body']) {
expect(tags.has(tag)).toBe(true);
}
});
// I tag `eventi.*` restano nel seed — in produzione le righe esistono già — ma dal
// commit della landing dell'In-Sanity Hour non alimentano piu' nessuna pagina: la data
// vive nel suo tag, non nel titolo, da quando i due sono stati separati.
it('la data dell\'inaugurazione sta nel tag della data', () => {
expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.date')!.value).toContain('19 settembre 2026');
});
// La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti.
it('il menu punta alla pagina di scelta', () => {
expect(site.navigation.some((v) => v.href === '/promo-eventi')).toBe(true);
});
});