Merge remote-tracking branch 'origin/feat/split-apps' into merge-split

# Conflicts:
#	apps/sito/compose.yaml
This commit is contained in:
Adriano Dal Pastro
2026-09-04 10:09:29 +00:00
583 changed files with 56050 additions and 257 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 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: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 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.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+55
View File
@@ -0,0 +1,55 @@
---
import { agenda, disciplines, days, slots } from '../data/agenda';
import T from './content/T.astro';
---
<section class="section section--dark agenda">
<div class="container">
<div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
{disciplines.map((d, i) => (
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
))}
</div>
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
<div class="agenda__corner"></div>
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
{slots.map((slot, si) => (
<>
<T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
{days.map((_, dayIdx) => {
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
return entry
? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
: <div class="agenda__cell agenda__cell--empty"></div>;
})}
</>
))}
</div>
</div>
</section>
<style>
.agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
.agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
.agenda__tab.is-active { background: var(--c-accent); color: #fff; }
.agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
.agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
.agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
.agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
.agenda__cell em { font-style: normal; opacity: .85; }
.agenda__cell--empty { background: transparent; }
.agenda__cell.is-dim { opacity: .15; }
@media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
</style>
<script>
document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
tabs.forEach((tab) => tab.addEventListener('click', () => {
tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
const f = tab.dataset.filter!;
cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
}));
});
</script>
@@ -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>
@@ -0,0 +1,58 @@
---
import { t } from '../lib/content';
import T from './content/T.astro';
interface Props { variant?: 'full' | 'footer' }
const { variant = 'full' } = Astro.props;
---
<form
class:list={['cform', `cform--${variant}`]}
method="post"
action="/api/contact"
novalidate
data-msg-success={t('form.msg.success')}
data-msg-error-generic={t('form.msg.error-generic')}
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="lastName" placeholder={t('form.field.last-name')} required maxlength="100" />
<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" />
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
<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>
<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>
</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>
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
const btn = form.querySelector<HTMLButtonElement>('button')!;
btn.disabled = true;
msg.hidden = true;
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const data = await res.json();
msg.textContent = res.ok ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
if (res.ok) form.reset();
} catch {
msg.textContent = form.dataset.msgErrorNetwork!;
msg.className = 'form-msg form-msg--err';
}
msg.hidden = false;
btn.disabled = false;
});
});
</script>
+54
View File
@@ -0,0 +1,54 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro';
const year = new Date().getFullYear();
---
<footer class="ftr">
<div class="container ftr__grid">
<div>
<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.about" as="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.2')}</p>
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
</div>
<nav aria-label="training">
<T tag="footer.col.training-title" as="h4" />
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
</nav>
<nav aria-label="servizi">
<T tag="footer.col.services-title" as="h4" />
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav>
</div>
<div class="ftr__bar">
<div class="container ftr__bar-in">
<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>
</div>
</div>
</footer>
<style>
.ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
.ftr a:hover { color: #fff; }
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
.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__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-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; }
@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; } }
</style>
+125
View File
@@ -0,0 +1,125 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
import { landingFor } from '../lib/auth';
interface Props {
user: { username: string; role: string } | null;
showTags: boolean;
canTags: boolean;
}
const { user, canTags, showTags } = Astro.props;
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';
---
<header class="hdr" id="site-header">
<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>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => (
(!('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>
)
))}
</nav>
<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">
<span></span><span></span><span></span>
</button>
</div>
</header>
<style>
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
.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__nav { display: flex; gap: 34px; }
.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--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 span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) {
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
.hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; }
.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__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>
<script>
const header = document.getElementById('site-header')!;
const toggle = document.getElementById('nav-toggle')!;
const nav = document.getElementById('site-nav')!;
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
toggle.addEventListener('click', () => {
const open = nav.classList.toggle('is-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>
+25
View File
@@ -0,0 +1,25 @@
---
import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets';
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
const { title, eyebrow, bgImage } = Astro.props;
const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
---
<section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
<div class="container" data-reveal>
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
</div>
</section>
<style>
/* Banner scuro come nel Figma: ~460px, titolo bianco maiuscolo ~44px */
.phero { background: var(--c-dark); min-height: 380px; display: flex; align-items: center; }
.phero .container { width: 100%; }
.phero h1 { text-transform: uppercase; font-weight: 500; color: #fff; font-size: clamp(2.2rem, 4vw, 2.75rem); letter-spacing: .04em; margin: 0; }
.phero--photo { position: relative; background-size: cover; background-position: center; }
.phero--photo::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.6); }
.phero--photo .container { position: relative; }
@media (max-width: 767px) { .phero { min-height: 240px; } }
</style>
@@ -0,0 +1,31 @@
---
import T from '../content/T.astro';
import { t } from '../../lib/content';
const stages = [1, 2, 3];
---
<section class="section">
<div class="container">
<div class="section-heading">
<T tag="about.storylab.title" as="h2" />
<T tag="about.storylab.body" as="p" />
</div>
<div class="tl">
<T tag="about.timeline.label" as="h3" class="tl__label" />
<T tag="about.timeline.open-text" as="p" />
{stages.map((n) => (
<details class="tl__stage">
<summary>+ {t(`about.timeline.stage${n}.label`)}</summary>
<T tag={`about.timeline.stage${n}.text`} as="p" />
</details>
))}
</div>
</div>
</section>
<style>
.tl { max-width: 720px; margin-inline: auto; }
.tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
.tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
.tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
.tl__stage summary::-webkit-details-marker { display: none; }
</style>
@@ -0,0 +1,136 @@
---
import { CATEGORIES } from '../../lib/blog-const';
import type { Post } from '../../lib/posts';
interface Props { post?: Post }
const { post } = Astro.props;
---
<form id="post-form" data-id={post?.id ?? ''}>
<input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
<input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
<select class="afield" name="category">
{CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
</select>
<textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
<label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
<input type="hidden" name="cover" value={post?.cover ?? ''} />
<p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
<div class="ed-toolbar" id="ed-toolbar">
<button type="button" data-cmd="h2">H2</button>
<button type="button" data-cmd="h3">H3</button>
<button type="button" data-cmd="bold"><b>B</b></button>
<button type="button" data-cmd="italic"><i>I</i></button>
<button type="button" data-cmd="bullet">• Elenco</button>
<button type="button" data-cmd="ordered">1. Elenco</button>
<button type="button" data-cmd="quote">" Citazione</button>
<button type="button" data-cmd="link">Link</button>
<button type="button" data-cmd="image">Immagine</button>
</div>
<div id="editor" class="ed-body"></div>
<label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
<p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
</form>
<style>
.ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
.ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
.ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
.ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
.ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
</style>
<script>
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
const form = document.getElementById('post-form') as HTMLFormElement;
const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Image,
Link.configure({ openOnClick: false }),
],
content: initial,
});
async function uploadFile(file: File): Promise<string | null> {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
if (!res.ok) { alert('Upload non riuscito.'); return null; }
return (await res.json()).url;
}
document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const c = editor.chain().focus();
switch (btn.dataset.cmd) {
case 'h2': c.toggleHeading({ level: 2 }).run(); break;
case 'h3': c.toggleHeading({ level: 3 }).run(); break;
case 'bold': c.toggleBold().run(); break;
case 'italic': c.toggleItalic().run(); break;
case 'bullet': c.toggleBulletList().run(); break;
case 'ordered': c.toggleOrderedList().run(); break;
case 'quote': c.toggleBlockquote().run(); break;
case 'link': {
const url = prompt('URL del link:');
if (url) c.setLink({ href: url }).run();
break;
}
case 'image': {
const input = document.createElement('input');
input.type = 'file'; input.accept = 'image/*';
input.onchange = async () => {
const url = input.files?.[0] && await uploadFile(input.files[0]);
if (url) editor.chain().focus().setImage({ src: url }).run();
};
input.click();
break;
}
}
});
document.getElementById('cover-file')!.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const url = await uploadFile(file);
if (url) {
(form.elements.namedItem('cover') as HTMLInputElement).value = url;
document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const id = form.dataset.id;
const body = {
title: (form.elements.namedItem('title') as HTMLInputElement).value,
slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
category: (form.elements.namedItem('category') as HTMLSelectElement).value,
excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
body_html: editor.getHTML(),
};
const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const msg = document.getElementById('save-msg')!;
if (res.ok) {
msg.textContent = 'Salvato ✓';
if (!id) location.href = '/admin';
} else {
msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
}
});
</script>
@@ -0,0 +1,26 @@
---
import { getDb } from '../../lib/db';
import { listRecent, listArchiveMonths } from '../../lib/posts';
import { CATEGORIES } from '../../lib/blog-const';
const db = getDb();
const recent = listRecent(db, 4);
const months = listArchiveMonths(db);
const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`));
---
<aside class="bside">
<h3>Categorie</h3>
{CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
<h3>Ultimi articoli</h3>
{recent.map((p) => (
<p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
))}
<h3>Archivio</h3>
{months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
</aside>
<style>
.bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
.bside a { text-decoration: none; color: var(--c-text); }
.bside a:hover { color: var(--c-accent-dark); }
.bside__month { text-transform: capitalize; }
</style>
+11
View File
@@ -0,0 +1,11 @@
---
import { resolveContent } from '../../lib/content';
interface Props { tag: string; as?: string; class?: string }
const { tag, as = 'span', class: cls } = Astro.props;
const Tag = as;
const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
---
{c.type === 'html'
? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
@@ -0,0 +1,16 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { resolveContent } from '../../lib/content';
interface Props {
tag: string; src: ImageMetadata; alt: string;
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
}
const { tag, src, alt, class: cls, ...rest } = Astro.props;
const c = resolveContent(tag);
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
const guid = c.guid || undefined;
---
{override
? <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} data-guid={guid} {...rest} />}
@@ -0,0 +1,34 @@
---
import featPerformance from '../../assets/img/feature-performance.jpg';
import featBalance from '../../assets/img/feature-balance.jpg';
import featLongevity from '../../assets/img/feature-longevity.jpg';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const blocks = [
{ key: 'performance', image: featPerformance, reverse: false },
{ key: 'balance', image: featBalance, reverse: true },
{ key: 'longevity', image: featLongevity, reverse: false },
];
---
<section class="section">
<div class="container">
{blocks.map((b) => (
<div class:list={['feat', { 'feat--reverse': b.reverse }]}>
<TImg tag={`home.feature.${b.key}.image`} src={b.image} alt={t(`home.feature.${b.key}.title`)} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
<T tag={`home.feature.${b.key}.title`} as="h2" />
<T tag={`home.feature.${b.key}.body`} as="p" />
<a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</a>
</div>
</div>
))}
</div>
</section>
<style>
.feat { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; }
.feat--reverse .feat__img { order: 2; }
.feat__text h2 { text-transform: uppercase; letter-spacing: .1em; }
@media (max-width: 767px) { .feat { grid-template-columns: 1fr; gap: 24px; } .feat--reverse .feat__img { order: 0; } }
</style>
@@ -0,0 +1,72 @@
---
// Foto hero rimossa ma conservata su disco per riuso futuro: assets/img/hero-1.png
import T from '../content/T.astro';
import { t } from '../../lib/content';
const slides = [
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'] },
];
---
<section class="hero" aria-label="presentazione">
{slides.map((s, i) => (
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
<div class="container hero__in">
<div class="hero__text">
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/services">{t('home.hero.cta')}</a>
</div>
</div>
</div>
))}
{slides.length > 1 && (
<>
<button class="hero__arrow hero__arrow--prev" aria-label="precedente"></button>
<button class="hero__arrow hero__arrow--next" aria-label="successiva"></button>
</>
)}
</section>
<style>
.hero { position: relative; background:
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
.hero__slide { display: none; }
.hero__slide.is-active { display: block; }
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
testo sovrapposto sulla sinistra. */
.hero__in { position: relative; display: flex; align-items: center; min-height: 0; padding-block: 90px; }
/* Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
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__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; }
@media (max-width: 991px) {
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
.hero__text { max-width: none; }
}
/* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(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__text h1 :global(.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__text h1 :global(.hero__line:nth-child(3)) { animation-delay: .45s; }
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
@media (prefers-reduced-motion: reduce) {
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn { opacity: 1; animation: none; }
}
</style>
<script>
const slides = document.querySelectorAll('.hero__slide');
if (slides.length > 1) {
let cur = 0;
const show = (i: number) => {
slides[cur].classList.remove('is-active');
cur = (i + slides.length) % slides.length;
slides[cur].classList.add('is-active');
};
document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
}
</script>
@@ -0,0 +1,32 @@
---
import { getImage } from 'astro:assets';
import infoPerformance from '../../assets/img/info-performance.jpg';
import infoBalance from '../../assets/img/info-balance.jpg';
import infoLongevity from '../../assets/img/info-longevity.jpg';
import T from '../content/T.astro';
import { contentImageUrl } from '../../lib/content';
// getImage garantisce l'emissione dell'asset anche quando il file è byte-identico
// ad altri import (Astro dedupa e non emette l'originale usato solo come background).
const cols = await Promise.all([
{ titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
{ titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
{ titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
---
<section class="info" aria-label="pilastri">
{cols.map((c, i) => (
<a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
<T tag={c.titleTag} as="span" class="info__title" />
</a>
))}
</section>
<style>
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
/* Proporzioni Figma: colonna 413x308 su frame 1209 — l'altezza scala con la larghezza */
.info__col { position: relative; aspect-ratio: 413 / 308; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
.info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
.info__col:hover::before { background: rgba(30,24,20,.15); }
.info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } .info__col { aspect-ratio: auto; } }
</style>
@@ -0,0 +1,55 @@
---
import type { ImageMetadata } from 'astro';
import { methodSteps } from '../../data/method';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
---
<section class="section section--alt">
<div class="container">
<div class="section-heading" data-reveal>
<T tag="home.method.title" as="h2" />
<T tag="method.intro" as="p" />
</div>
<T tag="method.extra-note" as="p" class="method__note" />
<div class="method__row">
{methodSteps.map((s, i) => (
<div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
<div class="method__circle">
{imgOf(s.image) && <TImg tag={`method.step.${s.n}.image`} src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
<span>{s.n}</span>
</div>
<T tag={`method.step.${s.n}.title`} as="h3" />
<T tag={`method.step.${s.n}.text`} as="p" />
</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>
</section>
<style>
.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__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
/* :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__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
.method__step p { font-size: .85rem; color: var(--c-text-light); }
@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>
@@ -0,0 +1,24 @@
---
import type { ImageMetadata } from 'astro';
import { partners } from '../../data/partners';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
---
<section class="pstrip" aria-label="partner">
<div class="container pstrip__in" data-reveal="fade">
{partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.name`)} height={44} />)}
</div>
</section>
<style>
.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 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>
@@ -0,0 +1,44 @@
---
import { getImage } from 'astro:assets';
import quoteBgSrc from '../../assets/img/quote-bg.jpg';
import { t, contentImageUrl } from '../../lib/content';
const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
---
<section class="quote" data-parallax>
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
<div class="quote__in">
<p class="quote__mark">“</p>
<blockquote>
<div class="quote__marquee">
<p class="quote__track">
<span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
<span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
</p>
</div>
<cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
</blockquote>
</div>
</section>
<style>
.quote { position: relative; overflow: hidden; text-align: center; }
/* Sfondo su div dedicato, esteso oltre i bordi per il margine di corsa del parallax */
.quote__bg { position: absolute; inset: -120px 0; background-size: cover; background-position: center; transform: translateY(var(--parallax-y, 0)); will-change: transform; }
.quote__bg::after { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
.quote__in { position: relative; padding-block: 110px; }
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
.quote blockquote { margin: 0; }
/* Marquee: testo a scorrimento infinito come il ticker del template */
.quote__marquee { overflow: hidden; white-space: nowrap; }
.quote__track { display: inline-flex; margin: 0; animation: quote-scroll 30s linear infinite; will-change: transform; }
.quote__marquee:hover .quote__track { animation-play-state: paused; }
.quote__seg { padding-right: 4em; color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
@keyframes quote-scroll { to { transform: translateX(-50%); } }
.quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
@media (prefers-reduced-motion: reduce) {
.quote__track { animation: none; white-space: normal; display: block; max-width: 820px; margin-inline: auto; padding-inline: 20px; }
.quote__seg { padding-right: 0; }
.quote__seg[aria-hidden] { display: none; }
.quote__bg { transform: none; }
}
</style>
@@ -0,0 +1,40 @@
---
import { services } from '../../data/services';
import { serviceIcons as icons } from '../../data/service-icons';
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">
<div class="container">
<div class="section-heading" data-reveal>
<T tag="home.services.title" as="h2" />
<T tag="home.services.body" as="p" />
</div>
<div class="pgrid">
{gridServices.map((p, i) => (
<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>
<T tag={`service.${p.slug}.title`} as="h3" />
<T tag={`service.${p.slug}.subtitle`} as="p" />
</a>
))}
</div>
</div>
</section>
<style>
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
.pgrid__item { text-decoration: none; transition: transform .3s ease; }
.pgrid__item:hover { transform: translateY(-4px); }
.pgrid__item:hover h3 { color: var(--c-accent-dark); }
.pgrid__item h3 { transition: color .3s ease; }
.pgrid__item svg { margin-inline: auto; }
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,40 @@
---
import type { ImageMetadata } from 'astro';
import { team, homeTeam } from '../../data/team';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(`${name}.`))?.[1].default;
---
<section class="section">
<div class="container">
<div class="tteam__head" data-reveal>
<T tag="home.team.title" as="h2" />
<a class="btn" href="/about"><T tag="home.team.cta" as="span" /></a>
</div>
<div class="tteam__grid">
{members.map((m, i) => {
const slug = m.shortName.toLowerCase();
return (
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
<TImg tag={`team.${slug}.image`} src={imgOf(m.image!)!} alt={t(`team.${slug}.name`)} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
<T tag={`team.${slug}.short-name`} as="figcaption" />
</figure>
);
})}
</div>
</div>
</section>
<style>
.tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
.tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
.tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
.tteam__card { position: relative; margin: 0; overflow: hidden; }
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; transition: transform .5s ease; }
.tteam__card:hover img { transform: scale(1.06); }
.tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
@media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
</style>
@@ -0,0 +1,60 @@
---
import type { ImageMetadata } from 'astro';
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'];
// riga 1 (training 0-1): card, foto | riga 2 (training 2-3): foto, card
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
---
<section class="tcards">
{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`}>
<T tag={`training.${tr.id}.title`} as="h3" />
<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>
</div>
);
const photo = img ? (
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
<TImg tag={`training.${tr.id}.image`} src={img} alt={t(`training.${tr.id}.title`)} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
</div>
) : null;
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
})}
</section>
<style>
.tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
.tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; aspect-ratio: 1; }
.tcards__cell--taupe { background: #6b5d5b; }
.tcards__cell--dark { background: var(--c-dark); }
.tcards__cell--charcoal { background: #27252a; }
.tcards__cell--taupe h3, .tcards__cell--taupe p,
.tcards__cell--dark h3, .tcards__cell--dark p,
.tcards__cell--charcoal h3, .tcards__cell--charcoal p { color: #efe9e3; }
.tcards__cell--tan { background: var(--c-accent); }
.tcards__cell--tan h3, .tcards__cell--tan p { color: #fff; }
.tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
.tcards__cell p { font-size: .88rem; }
.tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
.tcards__photo { overflow: hidden; }
.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); }
@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>
+36
View File
@@ -0,0 +1,36 @@
export const disciplines = ["Events", "Crossfit", "Cardio", "Box", "Meditation", "Yoga Classes", "Body Balance"] as const;
export const days = ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"] as const;
export const slots = ["10.00", "12.00", "16.00", "18.00", "20.00"] as const;
export interface AgendaEntry { day: number; slot: string; discipline: string; time: string; trainer: string }
// Placeholder dal template Prowess (come nel Figma); il cliente fornirà lorario reale.
export const agenda: AgendaEntry[] = [
{ day: 0, slot: "10.00", discipline: "Crossfit", time: "10.00 - 11.00", trainer: "Amanda Snow" },
{ day: 0, slot: "12.00", discipline: "Box", time: "12.00 - 13.00", trainer: "Matthew Dunn" },
{ day: 0, slot: "18.00", discipline: "Cardio", time: "18.00 - 19.00", trainer: "Randy Nelson" },
{ day: 0, slot: "20.00", discipline: "Crossfit", time: "20.00 - 21.00", trainer: "Amanda Snow" },
{ day: 1, slot: "10.00", discipline: "Cardio", time: "10.00 - 11.00", trainer: "Randy Nelson" },
{ day: 1, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
{ day: 1, slot: "16.00", discipline: "Body Balance", time: "16.00 - 17.00", trainer: "Barbara Holland" },
{ day: 1, slot: "20.00", discipline: "Box", time: "20.00 - 21.00", trainer: "Matthew Dunn" },
{ day: 2, slot: "10.00", discipline: "Crossfit", time: "10.00 - 11.00", trainer: "Amanda Snow" },
{ day: 2, slot: "12.00", discipline: "Meditation", time: "12.00 - 13.00", trainer: "Nicole Fuller" },
{ day: 2, slot: "16.00", discipline: "Box", time: "16.00 - 17.00", trainer: "Matthew Dunn" },
{ day: 2, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Barbara Holland" },
{ day: 3, slot: "10.00", discipline: "Body Balance", time: "10.00 - 11.00", trainer: "Barbara Holland" },
{ day: 3, slot: "12.00", discipline: "Box", time: "12.00 - 13.00", trainer: "Matthew Dunn" },
{ day: 3, slot: "16.00", discipline: "Crossfit", time: "16.00 - 17.00", trainer: "Amanda Snow" },
{ day: 3, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Randy Nelson" },
{ day: 4, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
{ day: 4, slot: "16.00", discipline: "Body Balance", time: "16.00 - 17.00", trainer: "Barbara Holland" },
{ day: 4, slot: "18.00", discipline: "Cardio", time: "18.00 - 19.00", trainer: "Randy Nelson" },
{ day: 4, slot: "20.00", discipline: "Yoga Classes", time: "20.00 - 21.00", trainer: "Amanda Snow" },
{ day: 5, slot: "10.00", discipline: "Cardio", time: "10.00 - 11.00", trainer: "Randy Nelson" },
{ day: 5, slot: "12.00", discipline: "Body Balance", time: "12.00 - 13.00", trainer: "Barbara Holland" },
{ day: 5, slot: "18.00", discipline: "Box", time: "18.00 - 19.00", trainer: "Matthew Dunn" },
{ day: 6, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
{ day: 6, slot: "16.00", discipline: "Cardio", time: "16.00 - 17.00", trainer: "Randy Nelson" },
{ day: 6, slot: "18.00", discipline: "Box", time: "18.00 - 19.00", trainer: "Matthew Dunn" },
{ day: 6, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Barbara Holland" },
];
+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' }));
}
+604
View File
@@ -0,0 +1,604 @@
import type { StyleGroup } from '../lib/style-presets';
import { site } from './site';
import { trainings } from './trainings';
import { team } from './team';
import { methodIntro, methodExtraNote, methodSteps } from './method';
import { partners } from './partners';
import { disciplines, days, slots } from './agenda';
import { services } from './services';
import { performanceClassPricing } from './pricing';
export type ContentType = 'text' | 'html' | 'image';
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style', 'align']): SeedEntry =>
({ tag, type: 'text', value, styleOptions });
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'align']): SeedEntry =>
({ tag, type: 'html', value, styleOptions });
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
export const contentSeed: SeedEntry[] = [
// --- Globali (site.ts) ---
T('global.contact.phone', site.phone, []),
T('global.contact.email', site.email, []),
T('global.contact.address', site.address, []),
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.2', site.hours[1], []),
T('global.nav.1.label', site.navigation[0].label, []),
T('global.nav.2.label', site.navigation[1].label, []),
T('global.nav.3.label', site.navigation[2].label, []),
T('global.nav.4.label', site.navigation[3].label, []),
T('global.nav.5.label', site.navigation[4].label, []),
T('global.nav.6.label', site.navigation[5].label, []),
// --- Header ---
T('header.logo.alt', 'InsanityLab', []),
I('header.logo.image'),
// --- Footer ---
I('footer.logo.image'),
H('footer.tagline', 'PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.'),
T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'),
T('footer.col.training-title', 'Training'),
T('footer.col.services-title', 'Servizi'),
T('footer.col.contact-title', 'Contact'),
H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'),
T('footer.social-label', 'Seguici', []),
T('footer.training.1.label', site.footerTraining[0].label, []),
T('footer.training.2.label', site.footerTraining[1].label, []),
T('footer.training.3.label', site.footerTraining[2].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.2.label', site.footerServices[1].label, []),
T('footer.services.3.label', site.footerServices[2].label, []),
T('footer.services.4.label', site.footerServices[3].label, []),
T('footer.services.5.label', site.footerServices[4].label, []),
T('footer.services.6.label', site.footerServices[5].label, []),
// --- Contact form ---
T('form.field.first-name', 'Nome', []),
T('form.field.last-name', 'Cognome', []),
T('form.field.phone', 'Telefono', []),
T('form.field.email', 'E-mail', []),
T('form.field.message', 'Messaggio', []),
T('form.submit', 'Invia messaggio'),
T('form.msg.success', 'Messaggio inviato, ti ricontatteremo al più presto.', []),
T('form.msg.error-generic', 'Errore di invio, riprova più tardi.', []),
T('form.msg.error-network', 'Errore di rete, riprova più tardi.', []),
// --- Home: meta, training heading ---
T('home.meta.title', 'Performance. Balance. Longevity.', []),
T('home.training.title', 'Training'),
T('home.training.subtitle', 'Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.'),
// --- Home: hero ---
T('home.hero.title-line-1', 'PERFORMANCE.'),
T('home.hero.title-line-2', 'BALANCE.'),
T('home.hero.title-line-3', 'LONGEVITY.'),
T('home.hero.cta', 'Scopri'),
I('home.hero.image'),
// --- Home: info columns ---
T('home.info.col1.title', 'PERFORMANCE.'),
T('home.info.col2.title', 'BALANCE.'),
T('home.info.col3.title', 'LONGEVITY.'),
I('home.info.col1.image'),
I('home.info.col2.image'),
I('home.info.col3.image'),
// --- Home: feature blocks ---
T('home.feature.performance.title', 'PERFORMANCE'),
T('home.feature.performance.body', 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.'),
T('home.feature.performance.cta', 'Scopri'),
T('home.feature.balance.title', 'BALANCE'),
T('home.feature.balance.body', 'Lequilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.'),
T('home.feature.balance.cta', 'Scopri'),
T('home.feature.longevity.title', 'LONGEVITY'),
T('home.feature.longevity.body', 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.'),
T('home.feature.longevity.cta', 'Scopri'),
I('home.feature.performance.image'),
I('home.feature.balance.image'),
I('home.feature.longevity.image'),
// --- Home: quote banner ---
H('home.quote.text', '<strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.'),
T('home.quote.cite', 'Insanitylab', []),
I('home.quote.image'),
// --- Home: training cards (data-driven da trainings.ts) ---
T('home.training-cards.link-label', 'Scopri', []),
...trainings.flatMap((tr) => [
T(`training.${tr.id}.title`, tr.title),
T(`training.${tr.id}.caption`, tr.caption),
T(`training.${tr.id}.description`, tr.description),
T(`training.${tr.id}.cta-label`, tr.cta.label, []),
I(`training.${tr.id}.image`),
]),
// --- Home: servizi ---
T('home.services.title', 'Servizi'),
T('home.services.body', 'Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.'),
// --- Home: team (data-driven da team.ts) ---
H('home.team.title', 'Team<br />Trainer'),
T('home.team.cta', 'Scopri team', []),
...team.flatMap((m) => {
const slug = m.shortName.toLowerCase();
return [
T(`team.${slug}.name`, m.name, []),
T(`team.${slug}.short-name`, m.shortName),
T(`team.${slug}.role`, m.role, []),
T(`team.${slug}.bio`, m.bio),
I(`team.${slug}.image`),
I(`team.${slug}.image-about`),
];
}),
// --- Home: metodo (data-driven da method.ts) ---
H('home.method.title', 'Il metodo<br />InsanityLab'),
T('home.method.cta', 'Contattaci', []),
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
T('method.extra-note', methodExtraNote, []),
...methodSteps.flatMap((s) => [
T(`method.step.${s.n}.title`, s.title),
T(`method.step.${s.n}.text`, s.text),
I(`method.step.${s.n}.image`),
]),
// --- Home: partner (data-driven da partners.ts) ---
...partners.flatMap((p, i) => [
T(`partner.${i + 1}.name`, p.name, []),
I(`partner.${i + 1}.image`),
]),
// --- Agenda (scope limitato: titolo, discipline, giorni, slot — celle escluse) ---
T('agenda.title', 'Agenda'),
...disciplines.map((d, i) => T(`agenda.discipline.${i + 1}`, d, [])),
...days.map((d, i) => T(`agenda.day.${i + 1}`, d, [])),
...slots.map((s, i) => T(`agenda.slot.${i + 1}`, s, [])),
// --- 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', 'About us', []),
// --- About: mission ---
T('about.mission.title', 'Mission'),
T('about.mission.body', 'In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.'),
I('about.mission.image'),
// --- About: direzione + approccio ---
T('about.direzione.title', 'Direzione'),
T('about.direzione.body', 'Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.'),
T('about.direzione.cta', 'Scopri'),
T('about.approccio.title', 'Approccio'),
T('about.approccio.body', 'InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.'),
T('about.approccio.cta', 'Contattaci'),
// --- About: about ---
T('about.about.title', 'About'),
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'),
// --- About: 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.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'),
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.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
I('about.convenzioni.image'),
// --- About: team ---
T('about.team.title', 'Team'),
T('about.team.body', 'Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.'),
T('about.team.staff-intro', 'Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:'),
// --- About: metodo ---
T('about.metodo.title', 'Metodo'),
T('about.metodo.body', 'Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.'),
T('about.metodo.closing', 'In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.'),
I('about.metodo.image'),
// --- About: 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 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.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'),
// --- About: vision ---
T('about.vision.title', 'Vision'),
T('about.vision.body1', "InsanityLab nasce con l'obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l'estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita."),
T('about.vision.body2', "L'obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità."),
I('about.vision.image'),
// --- About: storylab + timeline (Timeline.astro) ---
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 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', []),
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
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, 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ù →', []),
// --- Services: dettaglio (data-driven da services.ts) ---
...services.flatMap((p) => [
T(`service.${p.slug}.title`, p.title),
T(`service.${p.slug}.subtitle`, p.subtitle),
T(`service.${p.slug}.excerpt`, p.excerpt),
T(`service.${p.slug}.long-description`, p.longDescription),
...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
...p.faq.flatMap((item, i) => [
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`),
]),
// --- Services: dettaglio, template ---
T('service.side.categories-label', 'Categorie'),
T('service.side.category.1', 'Fitness', []),
T('service.side.category.2', 'Salute', []),
T('service.side.category.3', 'Stile di vita', []),
T('service.side.category.4', 'Nutrizione', []),
T('service.side.others-label', 'Altri servizi'),
H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
T('service.pricing.per', 'al mese, semestrale', []),
T('service.pricing.entries-singular', 'ingresso settimanale', []),
T('service.pricing.entries-plural', 'ingressi settimanali', []),
T('service.pricing.buy-cta', 'Acquista', []),
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
T('pricing.heading', performanceClassPricing.heading),
T('pricing.intro', performanceClassPricing.intro),
...performanceClassPricing.groups.flatMap((g, gi) => [
T(`pricing.group.${gi + 1}.label`, g.label),
...g.plans.flatMap((plan, pi) => [
T(`pricing.group.${gi + 1}.plan.${pi + 1}.note`, plan.note, []),
T(`pricing.group.${gi + 1}.plan.${pi + 1}.installments`, plan.installments, []),
T(`pricing.group.${gi + 1}.plan.${pi + 1}.best`, plan.best, []),
]),
]),
// --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []),
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 ---
T('contact.meta.title', 'Contatti', []),
T('contact.meta.description', 'Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.', []),
T('contact.hero.title', 'Contattaci', []),
I('contact.hero.image'),
T('contact.map.button', 'Carica la mappa (Google Maps)', []),
T('contact.map.note', 'La mappa viene caricata da Google solo dopo il tuo consenso.', []),
T('contact.info.address-label', 'Indirizzo'),
T('contact.info.phone-label', 'Telefono'),
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', []),
T('notfound.title', 'Pagina non trovata'),
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
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]));
+9
View File
@@ -0,0 +1,9 @@
export const methodIntro = "Il Metodo Insanitylab si basa su un percorso strutturato in fasi, dove le prime due rappresentano il focus standard del lavoro: valutazione iniziale e avvio dellallenamento personalizzato. A queste possono essere aggiunti servizi e approfondimenti extra, in base alle esigenze e agli obiettivi della persona.";
export const methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard.";
export const methodSteps = [
{ 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: "Check-up fisico", text: "Profilazione fisica per definire lo stato di salute iniziale del cliente.", extra: false, image: "method-step-2" },
{ 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: "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" },
];
+6
View File
@@ -0,0 +1,6 @@
export const partners = [
{ name: "Ticket Welfare Edenred", image: "partner-edenred" },
{ name: "Welbee", image: "partner-welbee" },
{ name: "AON", image: "partner-aon" },
{ name: "Howden", image: "partner-howden" },
];
+17
View File
@@ -0,0 +1,17 @@
export const performanceClassPricing = {
heading: "Acquista la tua performance class",
intro: "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.",
groups: [
{
label: "Programmi performance class semestrale",
open: true,
plans: [
{ monthly: 130, entries: 1, note: "tot: 780€ · da 23€ lezione singola", installments: "2 rate da 390€ inizio 60", best: "best price 720€" },
{ monthly: 234, entries: 2, note: "tot: 1.400€ · da 23€ lezione singola", installments: "2 rate da 700€ inizio 60", best: "best price 1.250€" },
{ monthly: 300, entries: 3, note: "tot: 1.800€ · da 23€ lezione singola", installments: "2 rate da 900€ inizio 60", best: "best price 1.650€" },
],
},
{ label: "Programmi performance class trimestrale", open: false, plans: [] },
{ label: "Programmi performance class mensile", open: false, plans: [] },
],
};
+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;
}
+15
View File
@@ -0,0 +1,15 @@
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
export const serviceIcons: Record<string, string> = {
'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',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'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',
// 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',
};
+204
View File
@@ -0,0 +1,204 @@
export interface Service {
slug: 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;
excerpt: string;
longDescription: string;
features: string[];
faq: { q: string; a: string }[];
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[] = [
{
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.",
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: [
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo lanamnesi, 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: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
],
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. 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 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",
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.",
features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
faq: [
{ q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso allinterno di un piano più ampio." },
{ q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
{ q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
{ q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
{ 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: false,
},
{
slug: "coaching",
title: "Coaching",
subtitle: "Programma di allenamento e monitoraggio",
excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nellallenamento.",
longDescription: "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 (formula Fit Remote).",
features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
faq: [
{ q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
{ q: "Serve attrezzatura?", a: "Il programma viene costruito sullattrezzatura che hai a disposizione, in palestra o a casa." },
{ q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
],
hasPricing: false,
},
{
// 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: "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." },
],
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).
slug: "osteopatia",
title: "Osteopatia",
subtitle: "Approccio manuale globale non invasivo",
excerpt: "Losteopatia è una delle colonne portanti dellapproccio integrato di InsanityLab. Non è un servizio aggiunto: è parte della visione.",
longDescription: "Losteopatia è 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 lallenamento, perché permette di lavorare su compensi e rigidità che altrimenti limiterebbero i progressi. Nel nostro approccio rientrano anche losteopatia neonatale e pediatrica.",
features: [],
faq: [
{ q: "Losteopatia è preventiva o curativa?", a: "Entrambe: è utile sia per mantenere lequilibrio corporeo sia per affrontare un problema specifico, in sinergia con lallenamento." },
{ q: "Trattate anche neonati e bambini?", a: "Sì, il nostro approccio comprende anche losteopatia neonatale e pediatrica." },
{ q: "Serve una prescrizione medica?", a: "Non è necessaria; se disponibile, eventuale documentazione clinica è utile alla prima valutazione." },
],
hasPricing: false,
},
{
slug: "nutrition",
title: "Nutrizione",
subtitle: "Alimentazione e stile di vita",
excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
features: ["Piani alimentari su misura", "Integrazione con lallenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
faq: [
{ q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
{ q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
{ q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
],
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 {
return services.find((p) => p.slug === slug);
}
+38
View File
@@ -0,0 +1,38 @@
export const site = {
name: "InsanityLab",
phone: "351 7535977",
email: "info@insanitylab.it",
// 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",
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: [
{ label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
{ label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
],
navigation: [
{ label: "Training", href: "/training" },
{ label: "Servizi", href: "/services" },
{ label: "About us", href: "/about" },
{ label: "Blog & Edugo", href: "/blog" },
{ label: "Promo ed eventi", href: "/promo-eventi" },
{ label: "Contatti", href: "/contact" },
],
footerTraining: [
{ label: "One to one", href: "/training#one-to-one" },
{ 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: "/training/one-to-one" },
{ label: "Class", href: "/training/class" },
{ label: "Coaching", href: "/services/coaching" },
{ label: "Check-up", href: "/services/profilazione" },
{ label: "Osteopatia", href: "/services/osteopatia" },
{ label: "Nutrizione", href: "/services/nutrition" },
],
};
+22
View File
@@ -0,0 +1,22 @@
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; imageAbout?: string; founder: boolean }
export const team: Member[] = [
{ name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, imageAbout: "team-donata-dark", image: "team-donata",
bio: "Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona larea dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza." },
{ name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, imageAbout: "team-nicola-dark", image: "team-nicola",
bio: "Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e allallenamento avanzato." },
{ name: "Eva Cassetta", shortName: "Eva", role: "Business Manager Balance & Strategy", founder: true, imageAbout: "team-eva-dark",
bio: "Eva Cassetta ricopre il ruolo di Business Manager Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo." },
{ name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer Functional & Movement", founder: false, imageAbout: "team-alessandro-dark",
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",
bio: "Trainer Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
{ 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 lascolto umano guida e supporta ogni tuo progresso." },
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer Functional & Movement", founder: false, imageAbout: "team-davide-dark",
bio: "Trainer Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature." },
{ name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer Pilates, Yoga & Mindfulness", founder: false, imageAbout: "team-giulia-dark",
bio: "Trainer Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e allequilibrio mente-corpo." },
];
export const homeTeam = ["Donata", "Nicola", "Eleonora"];
+35
View File
@@ -0,0 +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: "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);
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user?: { id: number; username: string; role: string };
}
}
+190
View File
@@ -0,0 +1,190 @@
---
// 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 }
const { title } = Astro.props;
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>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Admin InsanityLab</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body class="ad">
<header class="abar">
<a class="abar__brand" href="/admin">
<span class="abar__eyebrow">InsanityLab</span>
Area riservata
</a>
{user && (
<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>
</html>
+280
View File
@@ -0,0 +1,280 @@
---
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/montserrat/700.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { getDb } from '../lib/db';
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
const user = tok ? getSessionUser(getDb(), tok) : null;
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
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>
<html lang="it">
<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 name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title>
<meta name="description" content={description} />
<meta property="og:title" content={`${title} — InsanityLab`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap.xml" />
<script is:inline>document.documentElement.classList.add('js');</script>
</head>
<body>
{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>
<Footer />
<script>
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-inview');
io.unobserve(e.target);
// A reveal concluso l'attributo va tolto, così la transition del reveal
// non rallenta gli hover su transform del componente.
e.target.addEventListener('transitionend', () => {
e.target.removeAttribute('data-reveal');
}, { once: true });
}
}
}, { rootMargin: '0px 0px -100px 0px' });
document.querySelectorAll('[data-reveal], .section-heading').forEach((el) => io.observe(el));
const parallaxEls = document.querySelectorAll<HTMLElement>('[data-parallax]');
if (parallaxEls.length && matchMedia('(min-width: 992px)').matches
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
let raf = 0;
const update = () => {
raf = 0;
for (const el of parallaxEls) {
const r = el.getBoundingClientRect();
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
}
};
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
update();
}
</script>
{showTags && (
<style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
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; }
.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>
<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>
(() => {
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'] };
const GROUP_LABELS = { size: 'Dimensione', color: 'Colore', weight: 'Peso', style: 'Stile', align: 'Allineamento' };
const VALUE_LABELS = {
size: { s: 'S', m: 'M', l: 'L', xl: 'XL', xxl: 'XXL' },
weight: { normal: 'Normale', bold: 'Grassetto' },
style: { normal: 'Normale', italic: 'Corsivo' },
align: { left: 'Sinistra', center: 'Centro', right: 'Destra', justify: 'Giustificato' },
color: { accent: 'Accento', 'accent-dark': 'Accento scuro', dark: 'Scuro', heading: 'Titolo', text: 'Testo', 'text-light': 'Testo chiaro', white: 'Bianco' },
};
const valLabel = (g, v) => (VALUE_LABELS[g] && VALUE_LABELS[g][v]) || v;
const modal = document.getElementById('tag-modal');
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>
)}
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
import type Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7;
// 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 (apps/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 {
return bcrypt.hashSync(plain, 12);
}
export function verifyPassword(plain: string, hash: string): boolean {
return bcrypt.compareSync(plain, hash);
}
export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), role);
return Number(res.lastInsertRowid);
}
export function login(db: Database.Database, username: string, password: string): string | null {
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
| { id: number; password_hash: string } | undefined;
if (!user || !verifyPassword(password, user.password_hash)) return null;
const token = randomBytes(32).toString('hex');
db.prepare(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`
).run(token, user.id);
return token;
}
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null {
const row = db.prepare(
`SELECT s.token, s.expires_at, u.id, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined;
if (!row) return null;
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
return null;
}
return { id: row.id, username: row.username, role: row.role };
}
export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
// 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 {
if (role === 'admin') return true;
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')
);
}
+6
View File
@@ -0,0 +1,6 @@
export const CATEGORIES = [
'Articoli tecnici & scientifici',
'Contenuti educativi',
'LinkedIn / Insight',
'Eventi & Presidi',
] as const;
+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' });
}
+34
View File
@@ -0,0 +1,34 @@
export interface ContactData {
firstName: string; lastName: string; phone: string;
email: string; message: string; hpField: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function str(v: unknown, max: number): string {
return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
}
export function validateContact(data: unknown):
| { ok: true; value: ContactData }
| { ok: false; error: string } {
if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
const d = data as Record<string, unknown>;
const value: ContactData = {
firstName: str(d.firstName, 100),
lastName: str(d.lastName, 100),
phone: str(d.phone, 40),
email: str(d.email, 200),
message: str(d.message, 5000),
// Honeypot: nome neutro per non farlo riempire dall'autofill del browser.
hpField: str(d.hp_field, 200),
};
if (value.hpField) return { ok: false, error: 'Richiesta non valida.' };
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 (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
return { ok: true, value };
}
+31
View File
@@ -0,0 +1,31 @@
import type Database from 'better-sqlite3';
import { validateStyles } from './style-presets';
import { sanitizeInline } from './sanitize';
interface Body { value?: unknown; styles?: unknown }
export function applyContentUpdate(db: Database.Database, tag: string, body: Body, username: string):
{ ok: true } | { ok: false; status: number; error: string } {
const row = db.prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined;
if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' };
const sets: string[] = [];
const args: unknown[] = [];
if (body.value !== undefined) {
if (typeof body.value !== 'string') return { ok: false, status: 400, error: 'Valore non valido.' };
if (row.type === 'image') return { ok: false, status: 400, error: 'Le immagini si aggiornano via upload.' };
sets.push('value = ?');
args.push(row.type === 'html' ? sanitizeInline(body.value) : body.value);
}
if (body.styles !== undefined) {
const v = validateStyles(body.styles);
if (!v.ok) return { ok: false, status: 400, error: v.error };
sets.push('styles = ?');
args.push(JSON.stringify(v.styles));
}
if (!sets.length) return { ok: false, status: 400, error: 'Nessuna modifica.' };
sets.push(`updated_at = datetime('now')`, 'updated_by = ?');
args.push(username, tag);
db.prepare(`UPDATE content_blocks SET ${sets.join(', ')} WHERE tag = ?`).run(...args);
return { ok: true };
}
+56
View File
@@ -0,0 +1,56 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { getDb } from './db';
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
import { stylesToClasses } from './style-presets';
interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
export function syncSeed(db: Database.Database): void {
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, randomUUID()); });
tx();
}
export function makeContentStore(dbGetter: () => Database.Database) {
let cache: Map<string, Entry> | null = null;
const load = (): Map<string, Entry> => {
if (cache) return cache;
const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as
{ tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
cache = new Map(rows.map((r) => {
let styles: Record<string, string> = {};
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof styles !== 'object' || styles === null) styles = {};
return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
}));
return cache;
};
return {
resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
const hit = load().get(tag);
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
const seed = seedByTag.get(tag);
if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
console.warn(`[content] tag sconosciuto: ${tag}`);
return { value: '', type: 'text', classes: '', guid: '' };
},
invalidate(): void { cache = null; },
};
}
const store = makeContentStore(getDb);
export const resolveContent = (tag: string) => store.resolve(tag);
export const t = (tag: string): string => store.resolve(tag).value;
export const invalidateContentCache = (): void => store.invalidate();
export function getImageOverride(tag: string): string | null {
const r = store.resolve(tag);
return r.type === 'image' && r.value ? `/uploads/${r.value}` : null;
}
export const contentImageUrl = (tag: string, fallback: string): string =>
getImageOverride(tag) ?? fallback;
+293
View File
@@ -0,0 +1,293 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { contentSeed } from '../data/content-seed';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
cover TEXT,
draft INTEGER NOT NULL DEFAULT 1,
published_at TEXT,
created_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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
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 '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_by TEXT
);
`;
export function createDb(path?: string): Database.Database {
const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
// Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role.
const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
if (!userCols.some((c) => c.name === 'role')) {
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
// 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
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
const TAG_RENAMES: [string, string][] = [
['home.programs.', 'home.services.'],
['footer.programs.', 'footer.services.'],
['programs.', 'services.'],
['program.', 'service.'],
];
const renameUpd = db.prepare('UPDATE OR IGNORE content_blocks SET tag = ? || substr(tag, ?) WHERE tag LIKE ?');
const renameDel = db.prepare('DELETE FROM content_blocks WHERE tag LIKE ?');
const renameTx = db.transaction(() => {
for (const [oldP, newP] of TAG_RENAMES) {
renameUpd.run(newP, oldP.length + 1, `${oldP}%`);
renameDel.run(`${oldP}%`);
}
// Tag singolo, non coperto dai prefissi: uguaglianza esatta.
db.prepare("UPDATE OR IGNORE content_blocks SET tag = 'footer.col.services-title' WHERE tag = 'footer.col.programs-title'").run();
db.prepare("DELETE FROM content_blocks WHERE tag = 'footer.col.programs-title'").run();
// I valori di default che nominavano la sezione cambiano con la rinomina. Il seed è
// INSERT OR IGNORE e non tocca le righe esistenti, quindi si aggiornano qui — ma solo
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
// il nuovo valore è quello del seed corrente per lo stesso tag.
const OLD_SECTION_DEFAULTS: [string, string][] = [
// La voce "Servizi" (ex "Programmi") è la 2ª del menu dopo il riordino (commit 5ff4771).
['global.nav.2.label', 'Programmi'],
['footer.col.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 unesperienza di allenamento completa e mirata.'],
['services.meta.title', 'Programmi'],
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
['services.hero.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."],
['service.side.others-label', 'Altri programmi'],
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
];
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
const next = seedValue.get(tag);
if (next !== undefined) updValue.run(next, tag, oldValue);
}
});
renameTx();
// 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();
// 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 unanamnesi 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 119 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: 817, non 819. 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 in apps/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;
}
let singleton: Database.Database | null = null;
export function getDb(): Database.Database {
if (!singleton) singleton = createDb();
return singleton;
}
+5
View File
@@ -0,0 +1,5 @@
export function getEnv(name: string, fallback?: string): string {
const v = process.env[name] ?? (import.meta.env ? import.meta.env[name] : undefined) ?? fallback;
if (v === undefined) throw new Error(`Variabile d'ambiente mancante: ${name}`);
return v;
}
+25
View File
@@ -0,0 +1,25 @@
import nodemailer from 'nodemailer';
import { getEnv } from './env';
import type { ContactData } from './contact';
export async function sendContactEmail(data: ContactData): Promise<void> {
const transporter = nodemailer.createTransport({
host: getEnv('SMTP_HOST'),
port: Number(getEnv('SMTP_PORT', '587')),
secure: Number(getEnv('SMTP_PORT', '587')) === 465,
auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
});
await transporter.sendMail({
from: getEnv('CONTACT_FROM'),
to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
replyTo: data.email,
subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
text: [
`Nome: ${data.firstName} ${data.lastName}`.trim(),
data.phone && `Telefono: ${data.phone}`,
`E-mail: ${data.email}`,
'',
data.message,
].filter(Boolean).join('\n'),
});
}
+21
View File
@@ -0,0 +1,21 @@
import { slugify } from './slug';
import { sanitizeHtml } from './sanitize';
import { CATEGORIES } from './blog-const';
export function parsePostBody(data: Record<string, unknown>) {
const title = String(data.title ?? '').trim();
if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
const category = String(data.category ?? '');
if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
const slug = slugify(String(data.slug ?? '') || title);
if (!slug) return { error: 'Slug non valido.' } as const;
return {
value: {
title, slug, category,
excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
body_html: sanitizeHtml(String(data.body_html ?? '')),
cover: data.cover ? String(data.cover) : null,
draft: Boolean(data.draft),
},
} as const;
}

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