Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts: # apps/sito/compose.yaml
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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à l’orario 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" },
|
||||
];
|
||||
@@ -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' }));
|
||||
}
|
||||
@@ -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. BALANCE. 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', 'L’equilibrio 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 un’esperienza 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 dall’inaugurazione del nuovo spazio.', []),
|
||||
T('promo-eventi.hero.title', 'Promo ed eventi', []),
|
||||
T('promo-eventi.intro', 'Scegli cosa vuoi vedere: le promozioni attive in questo momento oppure gli eventi in programma al centro.'),
|
||||
T('promo-eventi.promo.label', 'Promo'),
|
||||
T('promo-eventi.promo.desc', 'I pacchetti e le offerte del periodo, con prezzi e condizioni.'),
|
||||
T('promo-eventi.promo.nessuna', 'Nessuna promozione in corso', []),
|
||||
T('promo-eventi.promo.cta', 'Vedi le promo', []),
|
||||
T('promo-eventi.eventi.label', 'Eventi'),
|
||||
T('promo-eventi.eventi.desc', 'Le date da segnare: aperture, presentazioni e appuntamenti in sede.'),
|
||||
T('promo-eventi.eventi.cta', 'Vedi gli eventi', []),
|
||||
|
||||
// --- Eventi (/promo-eventi/eventi) ---
|
||||
T('eventi.meta.title', 'Eventi', []),
|
||||
T('eventi.meta.description', 'Gli eventi InsanityLab: inaugurazione del nuovo spazio il 19 settembre 2026, con aperitivo e dj set.', []),
|
||||
T('eventi.hero.title', 'Eventi', []),
|
||||
T('eventi.inaugurazione.title', 'Inaugurazione'),
|
||||
T('eventi.inaugurazione.date', '19 settembre 2026'),
|
||||
T('eventi.inaugurazione.body', 'Apriamo le porte del nuovo spazio con un aperitivo e dj set. Sarà l’occasione per conoscere lo staff, visitare la struttura e prenotare una prova gratuita dei nostri servizi. Il programma completo della giornata sarà pubblicato a breve.'),
|
||||
T('eventi.cta.label', 'Scrivici su WhatsApp', []),
|
||||
T('eventi.torna.label', '← Torna a promo ed eventi', []),
|
||||
|
||||
// --- 404 ---
|
||||
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]));
|
||||
@@ -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 dell’allenamento 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" },
|
||||
];
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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 un’esperienza 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: [] },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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: "L’allenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
|
||||
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È 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. Un’esperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Quante persone ci sono in un Personalized Training Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
// La Performance Class non si fa più (cliente, 17/08/2026): la scheda che la spiegava
|
||||
// sparisce (`page: false` → /services/performance-class risponde 404) mentre la card
|
||||
// "Class" resta in griglia e porta alla vista Training con Holistic e Reformer.
|
||||
// Il pricing è caduto con la scheda: era l'unico servizio ad averlo.
|
||||
slug: "performance-class",
|
||||
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 all’interno 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 all’interno 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 è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno 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à nell’allenamento.",
|
||||
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 sull’attrezzatura 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 — un’analisi completa e scientifica che guida le nostre scelte.",
|
||||
longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.",
|
||||
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
|
||||
faq: [
|
||||
{ q: "Quando si fa la profilazione?", a: "All’inizio 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 l’HRV attraverso sensori dedicati — un dato chiave per capire come il tuo corpo recupera e si adatta al carico di lavoro. Ogni dato raccolto guida scelte più precise nella costruzione del programma e nel monitoraggio dei progressi.",
|
||||
items: [
|
||||
"Composizione corporea di precisione (massa grassa, magra, metabolismo basale)",
|
||||
"Scansione posturale 3D e analisi di simmetria",
|
||||
"Spirometria e parametri cardiovascolari",
|
||||
"Analisi HRV tramite sensori dedicati",
|
||||
"Report immediato, confrontabile nel tempo",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
|
||||
slug: "osteopatia",
|
||||
title: "Osteopatia",
|
||||
subtitle: "Approccio manuale globale non invasivo",
|
||||
excerpt: "L’osteopatia è una delle colonne portanti dell’approccio integrato di InsanityLab. Non è un servizio aggiunto: è parte della visione.",
|
||||
longDescription: "L’osteopatia è indicata sia in modo preventivo, per chi vuole mantenere il proprio equilibrio corporeo, sia in modo curativo, per chi sta affrontando un problema specifico. È particolarmente utile in sinergia con l’allenamento, perché permette di lavorare su compensi e rigidità che altrimenti limiterebbero i progressi. Nel nostro approccio rientrano anche l’osteopatia neonatale e pediatrica.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "L’osteopatia è preventiva o curativa?", a: "Entrambe: è utile sia per mantenere l’equilibrio corporeo sia per affrontare un problema specifico, in sinergia con l’allenamento." },
|
||||
{ q: "Trattate anche neonati e bambini?", a: "Sì, il nostro approccio comprende anche l’osteopatia 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 l’allenamento", "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);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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 l’area 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 all’allenamento 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 l’ascolto 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 all’equilibrio mente-corpo." },
|
||||
];
|
||||
|
||||
export const homeTeam = ["Donata", "Nicola", "Eleonora"];
|
||||
@@ -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. Un’esperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } },
|
||||
{ id: "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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user?: { id: number; username: string; role: string };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const CATEGORIES = [
|
||||
'Articoli tecnici & scientifici',
|
||||
'Contenuti educativi',
|
||||
'LinkedIn / Insight',
|
||||
'Eventi & Presidi',
|
||||
] as const;
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 un’esperienza 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 un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', 'La base di partenza per definire le priorità del percorso di ciascun cliente.'],
|
||||
['method.step.2.title', 'Prima seduta operativa', 'Check-up fisico'],
|
||||
['method.step.2.text', 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', 'Profilazione fisica per definire lo stato di salute iniziale del cliente.'],
|
||||
['method.step.3.title', 'Plicometria', 'Insanity plan'],
|
||||
['method.step.3.text', 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', 'Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.'],
|
||||
['method.step.4.title', 'Analisi antropometriche', 'Monitoraggio costante'],
|
||||
['method.step.4.text', 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', 'Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.'],
|
||||
];
|
||||
const updMetodoTx = db.transaction(() => { for (const [tag, oldV, newV] of METODO) upd02.run(newV, tag, oldV); });
|
||||
updMetodoTx();
|
||||
|
||||
// Modifiche promo (commit 1522d97): la freccia "→" dei bottoni è passata al CSS (::after) e il
|
||||
// maiuscolo a text-transform. Il seed è ora minuscolo e senza freccia. Le righe di prod ancora
|
||||
// al vecchio default vanno allineate, altrimenti il CSS ::after produce una DOPPIA freccia.
|
||||
// Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
|
||||
const PROMO_FRECCE: [string, string, string][] = [
|
||||
['promo.hero.cta', 'LE NOSTRE PROPOSTE →', 'Le nostre proposte'],
|
||||
['promo.buy-label', 'ACQUISTA →', 'Acquista'],
|
||||
];
|
||||
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
|
||||
updPromoTx();
|
||||
|
||||
// Listino promo agosto 2026 (finestra 1–19 agosto): nuovi prezzi comunicati dal cliente e nota
|
||||
// di validità con le date. I prezzi barrati (price-old) restano invariati. UPDATE guardato dal
|
||||
// vecchio valore → idempotente e non sovrascrive eventuali ritocchi fatti dal pannello.
|
||||
const PROMO_AGOSTO_2026: [string, string, string][] = [
|
||||
['promo.card.1.price', '163 €', '200 €'],
|
||||
['promo.card.2.price', '110 €', '125 €'],
|
||||
['promo.card.3.price', '100 €', '115 €'],
|
||||
['promo.card.4.price', '50 €', '75 €'],
|
||||
['promo.validity-note',
|
||||
"Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026",
|
||||
'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026'],
|
||||
];
|
||||
const updAgostoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_AGOSTO_2026) upd02.run(newV, tag, oldV); });
|
||||
updAgostoTx();
|
||||
|
||||
// Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services,
|
||||
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
|
||||
// Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
|
||||
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'service.performance-class.title' AND value = 'Performance Class'").run();
|
||||
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'footer.services.2.label' AND value = 'Performance Class'").run();
|
||||
|
||||
// Titolo del servizio One to One: in prod era stato editato da pannello a "Personal training"
|
||||
// (t minuscola → la vecchia migrazione, guardata su 'Personal Training', non l'agganciava),
|
||||
// così l'hero e l'H2 di /services/one-to-one mostravano "PERSONAL TRAINING". Riportiamo il
|
||||
// nome canonico "One to One" (già nel seed). Guardato sul valore reale corrente → idempotente;
|
||||
// la descrizione lunga editata da pannello NON viene toccata.
|
||||
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal training'").run();
|
||||
|
||||
// Pulizia tag orfani: il riquadro training "performance-class" è stato rimosso dal seed
|
||||
// (commit 9966ec3, "Training: 6 riquadri"), ma i suoi tag restano nei DB esistenti — orfani,
|
||||
// non renderizzati da nessuna pagina. Rimuoviamo l'intero gruppo. Idempotente. Il seed non li
|
||||
// reinserisce (trainings.ts non ha più questo id).
|
||||
db.prepare("DELETE FROM content_blocks WHERE tag LIKE 'training.performance-class.%'").run();
|
||||
|
||||
// Modifiche 03: due ingressi dello stesso stabile → indirizzo esteso in footer e contatti.
|
||||
// Il tag global.contact.map-query è nuovo (inserito dal seed); qui si aggiorna solo l'indirizzo
|
||||
// già esistente, se non modificato dal pannello. Idempotente.
|
||||
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
|
||||
|
||||
// Orario sabato corretto: 8–17, non 8–19. Guardato dal vecchio valore di default →
|
||||
// idempotente, non tocca eventuali modifiche del pannello.
|
||||
db.prepare("UPDATE content_blocks SET value = 'Sab 8.00 – 17.00 · Dom chiuso' WHERE tag = 'global.hours.2' AND value = 'Sab 8.00 – 19.00 · Dom chiuso'").run();
|
||||
|
||||
// Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE
|
||||
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
|
||||
// rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta).
|
||||
const ABOUT_02: [string, string, string][] = [
|
||||
['about.about.body', '%unire fitness e salute in un unico percorso%',
|
||||
"InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."],
|
||||
['about.convenzioni.item1', '%small group training e classi di gruppo%',
|
||||
'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'],
|
||||
['about.convenzioni.item2', '%consulenza gratuita preliminare%',
|
||||
'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'],
|
||||
['about.studios.body1', '%della performance. Più che un semplice insieme%',
|
||||
"IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."],
|
||||
];
|
||||
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
|
||||
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
|
||||
updAboutTx();
|
||||
// ⚠️ Qui c'era la rinomina una tantum del ruolo `campus` in `piattaforme`. Tolta il
|
||||
// 03/09/2026 con la separazione: quel ruolo non esiste più in questo sito, e la
|
||||
// migrazione avrebbe scritto in `users` un valore che nessuna regola di lib/auth
|
||||
// riconosce. Gli utenti delle aree riservate vivono 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||