Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59dff13e22 | |||
| 5c39b04c31 | |||
| ae98c4b889 | |||
| c65effb0ed | |||
| 67dc554810 | |||
| 9126af28ce | |||
| 3fff3cd2f8 | |||
| 9efd9a7680 | |||
| 680224e773 | |||
| 8aeb25f930 | |||
| 575a3988b7 | |||
| 498395dddb | |||
| b0f30f9e4c | |||
| 81021d18f3 | |||
| c8187ce4db | |||
| cffb0a624d | |||
| f57a07a964 | |||
| abd0626ab0 | |||
| 2231a476c7 | |||
| cef08aaf9f | |||
| 37b1a316d8 | |||
| 00ecfe86c3 | |||
| 6bbfcd8f3c | |||
| 9e566ad7b1 | |||
| 19035e67b0 | |||
| 880c928f43 | |||
| 558def3d52 | |||
| 8f95a385d9 | |||
| 22a400b854 | |||
| 12b2e91503 | |||
| e74266f9a0 | |||
| 2c9a74f849 | |||
| 6cbb205064 | |||
| 8579199115 | |||
| d2b508e8ac | |||
| 948068b312 | |||
| 9375a0380d | |||
| 179aeb442a | |||
| fd56afdf44 | |||
| 8ee2c8a2b4 | |||
| 5dc6b85d52 | |||
| 4cd911098d | |||
| 922ab40eb2 | |||
| 77829b2d12 | |||
| f096807ed6 | |||
| 95ffa08197 | |||
| 758463bd26 | |||
| 8e0b9e4647 | |||
| af8afebba8 | |||
| 4968b75472 | |||
| 48910d84ba | |||
| 13d8cdd137 | |||
| 33091be802 | |||
| faf7647a5f | |||
| 2b71cbb49e | |||
| f914d0699a | |||
| a59b4e0c0e | |||
| c8a6b318cb | |||
| 62b19b21d9 | |||
| 89d2186e69 | |||
| f5fac89c1b | |||
| 15dbe6a397 | |||
| 665c598462 | |||
| a7b81e2ff4 | |||
| 4103376eb1 | |||
| 18b0877026 | |||
| 6301f521fd | |||
| 510d7ca4ec | |||
| c03edccd8e | |||
| b75e12de86 | |||
| 718ca499c4 | |||
| 4f031cf571 | |||
| 6ba8794770 | |||
| 0ea1cb5e77 | |||
| 7f96b95e04 | |||
| 13d73f90a8 | |||
| 7fd3961469 | |||
| 1d6bdd0b3d | |||
| a56398ea02 | |||
| 6630be7a02 | |||
| 7f01ee0b45 | |||
| 8c980750c0 | |||
| f0629db3c4 | |||
| f5ca841d92 | |||
| 2ea822a84d | |||
| 34c66ca438 | |||
| 779aef32e6 | |||
| 0e9b081a79 | |||
| 42a039ae6b | |||
| 82a4cd2000 | |||
| c1c868622f | |||
| 469251366e | |||
| c4524e3a73 | |||
| d22f88ad71 | |||
| ac8b377f84 | |||
| f238984828 | |||
| 93ecc3d367 | |||
| 98a633b3e1 | |||
| f029820a14 | |||
| 4d76030839 | |||
| 6f926e3197 | |||
| c63bb1fe80 | |||
| 36a2cf3fb6 | |||
| 11ecc8305b | |||
| 236cdce076 | |||
| c5249255f0 | |||
| 998487ed62 | |||
| 7cb49c6a49 | |||
| bc9d31e193 | |||
| 59b453c67e | |||
| 52ff28b69d | |||
| 763e5611e6 | |||
| e6c2ac9148 | |||
| 2024c32965 | |||
| 91e6487da3 | |||
| f21b64fcc5 | |||
| d3abbf9bf7 | |||
| 473c0d8c36 | |||
| 502ccb1585 | |||
| b7ceece4ed | |||
| 9943312071 | |||
| 7779c6b830 | |||
| a18bb7c02f | |||
| 370dcbda56 | |||
| 1821a0a8d4 | |||
| ad2fc0d510 | |||
| f16012c8bd | |||
| bdc44dd3c5 | |||
| 534833c767 | |||
| 02d0224e5a | |||
| ecab67805b | |||
| cdfbf194c2 | |||
| 76adef3fc7 | |||
| d079019dcf | |||
| 48909e8c8b | |||
| cc6945c3d1 | |||
| f7a9ae47d1 | |||
| 9772b3a3f6 | |||
| 32c8a510bb | |||
| 20f0c2120b | |||
| 6e002d4dc1 | |||
| 3790d6d506 | |||
| e315887964 | |||
| 0cbaf66a5b | |||
| 3741ce34ac | |||
| 69bd17a296 | |||
| d03c62641e | |||
| 928de34c55 | |||
| 5be0fa787c | |||
| 01f49ab6d2 | |||
| a18a505254 | |||
| cbcb533e57 | |||
| 0b5d9f4106 | |||
| cd73906c08 | |||
| d6c6597d0a | |||
| 97bf270906 | |||
| 63417fb8ea | |||
| 9000c78a22 | |||
| 12f6b088de | |||
| ab2797e487 | |||
| 11452ea7a7 | |||
| d8862ba8ec | |||
| b7f4a7948c | |||
| 5590a552cc | |||
| 770073c45c | |||
| 6a97a98325 | |||
| 59d6246c77 | |||
| 15924544b4 | |||
| 03510d3940 | |||
| 8207627425 | |||
| 3cda76b4c4 | |||
| c4eb598a3a | |||
| 78a920b4e2 | |||
| e8e24e086b | |||
| 2f0df9c92c | |||
| f534263c9c | |||
| 6b00b2cb28 | |||
| d8579c0a59 | |||
| 98beccb0cd | |||
| c4107b84a5 | |||
| 7baebebc09 | |||
| a7d918d241 | |||
| 20f7cf4003 | |||
| 669fd2978f |
@@ -1,8 +1,16 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
.env
|
.env
|
||||||
data/*.db*
|
.env.bak*
|
||||||
uploads/*
|
**/data/*.db*
|
||||||
!uploads/.gitkeep
|
**/uploads/*
|
||||||
assets-src/
|
!**/uploads/.gitkeep
|
||||||
|
**/assets-src/
|
||||||
|
|
||||||
|
# Artefatti di lavoro dei subagenti (ledger, brief, review package)
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Cache di Python generata rieseguendo l'oracolo
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# InsanityLab Website
|
# InsanityLab Website
|
||||||
|
|
||||||
Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**.
|
Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti, piattaforme riservate ai clienti abilitati e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**.
|
||||||
|
|
||||||
## Descrizione
|
## Descrizione
|
||||||
|
|
||||||
Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor rich‑text, e il form di contatti invia email tramite SMTP.
|
Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor rich‑text, e il form di contatti invia email tramite SMTP.
|
||||||
|
|
||||||
|
Accanto alla vetrina vivono due sezioni riservate, raccolte sotto `/piattaforme` e visibili solo agli utenti abilitati: il **Biohacking Campus** (guide di studio generate da un progetto esterno) e **Stress Index** (piattaforma di monitoraggio HRV). Quest'ultima è l'unica parte del sito che usa React, sotto forma di isole limitate ai grafici e alle viste interattive.
|
||||||
|
|
||||||
## Stack tecnologico
|
## Stack tecnologico
|
||||||
|
|
||||||
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
|
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
|
||||||
@@ -14,6 +16,8 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node
|
|||||||
- **Autenticazione**: sessioni con password hashate (`bcryptjs`)
|
- **Autenticazione**: sessioni con password hashate (`bcryptjs`)
|
||||||
- **Email**: Nodemailer (SMTP)
|
- **Email**: Nodemailer (SMTP)
|
||||||
- **Editor articoli**: TipTap (rich text)
|
- **Editor articoli**: TipTap (rich text)
|
||||||
|
- **Isole interattive**: React 19 via `@astrojs/react`, solo nella piattaforma Stress Index
|
||||||
|
- **Grafici**: Recharts (caricati unicamente sulle pagine della piattaforma)
|
||||||
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
|
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
|
||||||
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
|
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
|
||||||
- **Test**: Vitest
|
- **Test**: Vitest
|
||||||
@@ -25,8 +29,10 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node
|
|||||||
- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi".
|
- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi".
|
||||||
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
|
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
|
||||||
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
|
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
|
||||||
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo.
|
- **Blog** con editor TipTap, immagini caricate, firma autore, proprietà degli articoli per ruolo, filtro per tematica senza ricaricare la pagina e conteggio degli articoli per categoria.
|
||||||
- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`).
|
- **Cestino degli articoli**: l'eliminazione sposta in `/admin/cestino`, da dove si ripristina o si cancella in via definitiva.
|
||||||
|
- **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `piattaforme`), creazione su pagina dedicata e password impostabile a mano o generata.
|
||||||
|
- **Piattaforme riservate** (`/piattaforme`): indice delle sezioni accessibili ai soli utenti abilitati — Biohacking Campus e Stress Index.
|
||||||
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
|
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
|
||||||
|
|
||||||
## Installazione (sviluppo)
|
## Installazione (sviluppo)
|
||||||
@@ -53,7 +59,9 @@ Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato aut
|
|||||||
| `npm run preview` | Anteprima locale della build |
|
| `npm run preview` | Anteprima locale della build |
|
||||||
| `npm run start` | Server di produzione (`node --env-file=.env`) |
|
| `npm run start` | Server di produzione (`node --env-file=.env`) |
|
||||||
| `npm run test` | Test con Vitest |
|
| `npm run test` | Test con Vitest |
|
||||||
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user]` |
|
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user\|piattaforme]` |
|
||||||
|
| `npm run sync-campus` | Rigenera i contenuti del Campus: `-- <path-progetto-biohacking-campus>` |
|
||||||
|
| `npm run seed-blog` | Carica i venti articoli divulgativi tratti dal corso base del Campus (salta quelli già presenti) |
|
||||||
|
|
||||||
## Gestione contenuti
|
## Gestione contenuti
|
||||||
|
|
||||||
@@ -67,33 +75,65 @@ Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui conten
|
|||||||
|
|
||||||
> Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`).
|
> Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`).
|
||||||
|
|
||||||
|
## Blog
|
||||||
|
|
||||||
|
Gli articoli stanno nella tabella `posts` e si gestiscono da `/admin`. La pagina pubblica `/blog` filtra per tematica e pagina senza ricaricare — si scarica la stessa pagina e si sostituiscono soltanto griglia e filtri — mantenendo indirizzi veri e cronologia del browser: senza JavaScript la navigazione funziona come prima.
|
||||||
|
|
||||||
|
L'eliminazione di un articolo non cancella nulla: valorizza `deleted_at` e lo sposta nel **cestino** (`/admin/cestino`), da dove si ripristina o si elimina davvero. Ogni lettura pubblica — elenco, recenti, archivio per mese, pagina del singolo articolo — esclude il cestino, e chi non ha ruolo di gestione vede nel cestino soltanto i propri articoli.
|
||||||
|
|
||||||
|
Lo script `npm run seed-blog` carica venti articoli divulgativi ricavati dal corso base del Campus, dieci tecnici e dieci educativi. È versionato apposta: il database di produzione è un volume del VPS e il deploy non lo tocca, quindi articoli creati a mano in locale resterebbero in locale. Lo script salta gli slug già presenti e crea, se mancano, i due utenti che firmano gli articoli — la firma pubblica è lo `username` dell'autore.
|
||||||
|
|
||||||
## Ruoli e accessi
|
## Ruoli e accessi
|
||||||
|
|
||||||
Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
|
Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
|
||||||
|
|
||||||
| Ruolo | Può fare |
|
| Ruolo | Può fare |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) |
|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), piattaforme |
|
||||||
| `superuser` | Pannello contenuti + blog |
|
| `superuser` | Pannello contenuti + blog |
|
||||||
| `user` | Solo i **propri** articoli del blog |
|
| `user` | Solo i **propri** articoli del blog |
|
||||||
|
| `piattaforme` | Solo le sezioni riservate (`/piattaforme`, `/campus`); nessun accesso al pannello |
|
||||||
|
|
||||||
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
|
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, l'indice delle piattaforme per il ruolo `piattaforme`. La gestione utenti (`/admin/users`, solo admin) consente creazione su pagina dedicata (`/admin/users/new`), cambio password — scelta a mano o generata, mostrata una sola volta perché nel database resta il solo hash — cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
|
||||||
|
|
||||||
|
> Il ruolo `campus` è stato rinominato in `piattaforme` quando le sezioni riservate sono diventate due. Una migrazione in `src/lib/db.ts` aggiorna da sé gli utenti esistenti.
|
||||||
|
|
||||||
|
## Piattaforme riservate
|
||||||
|
|
||||||
|
Le sezioni riservate sono raccolte sotto `/piattaforme`, che è anche la pagina di atterraggio del ruolo omonimo. L'elenco vive in `src/data/piattaforme.ts`: aggiungerne una è una riga, e il menu dell'header la mostra da sé a chi ha i permessi.
|
||||||
|
|
||||||
|
### Biohacking Campus (`/campus`)
|
||||||
|
|
||||||
|
La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare all'indice delle piattaforme — ma usano i font, i colori e il CSS globale di InsanityLab.
|
||||||
|
|
||||||
|
I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`.
|
||||||
|
|
||||||
|
Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida.
|
||||||
|
|
||||||
|
> Il progetto sorgente sta in `/home/adriano/Wasabi-Adp-Work/AI-OS/projects/personale/biohacking-campus` (percorso predefinito dello script). In sviluppo, dopo un sync, il server va riavviato: `src/lib/campus.ts` tiene l'indice in cache in memoria e le guide nuove darebbero 404.
|
||||||
|
|
||||||
|
### Stress Index (`/piattaforme/stress-index`)
|
||||||
|
|
||||||
|
Piattaforma di monitoraggio HRV portata dentro il sito da un prototipo Next.js esterno: nove viste — cruscotto giornaliero, elenco clienti, scheda cliente, analytics di studio, modulo sport con sessioni e atleti, team live, impostazioni, organizzazione. Il design system del prototipo (Tailwind, palette teal) è stato sostituito con quello del sito: lo stile sta in `src/styles/stress-index.css`, un foglio globale prefissato `.si` perché le stesse classi servono sia ai componenti Astro sia alle isole React, che lo scope di Astro non raggiungerebbe.
|
||||||
|
|
||||||
|
React è limitato a ciò che ha davvero bisogno di stato: la scheda cliente, la vista Analytics, il modulo Sport e i grafici Recharts. Il resto è Astro con qualche riga di JavaScript. I dati sono **dimostrativi** e stanno in `src/lib/stress-index/data.ts`; le aggregazioni con una logica propria (finestre temporali, distribuzioni, riepiloghi atleta) vivono in `analytics.ts` e `sport.ts` accanto, coperte da test.
|
||||||
|
|
||||||
## Struttura del progetto
|
## Struttura del progetto
|
||||||
|
|
||||||
```
|
```
|
||||||
insanitylab-website/
|
insanitylab-website/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── pages/ # Route Astro (vetrina, /admin, /api)
|
│ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus, /piattaforme)
|
||||||
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
|
│ ├── components/ # Componenti (home/, content/, stress-index/, Header, Footer, …)
|
||||||
│ ├── layouts/ # Layout Base
|
│ ├── layouts/ # Layout Base, Admin, Campus, StressIndex
|
||||||
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
|
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, piattaforme, content-seed)
|
||||||
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
|
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env, stress-index/
|
||||||
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
||||||
│ └── styles/ # global.css
|
│ └── styles/ # global.css, stress-index.css
|
||||||
├── data/ # SQLite (volume in produzione)
|
├── data/ # SQLite (volume in produzione)
|
||||||
├── uploads/ # Upload immagini (volume in produzione)
|
├── uploads/ # Upload immagini (volume in produzione)
|
||||||
├── scripts/ # create-user, smoke test
|
├── campus-content/ # Contenuti del Campus generati da sync-campus
|
||||||
|
├── scripts/ # create-user, sync-campus, seed-blog-biohacking, smoke test
|
||||||
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
|
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
|
||||||
├── compose.yaml # Stack Docker + label Traefik
|
├── compose.yaml # Stack Docker + label Traefik
|
||||||
├── Dockerfile # Build multi-stage
|
├── Dockerfile # Build multi-stage
|
||||||
@@ -115,6 +155,7 @@ Il file `.env` (non versionato) richiede:
|
|||||||
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
||||||
| `DB_PATH` | Percorso del database SQLite |
|
| `DB_PATH` | Percorso del database SQLite |
|
||||||
| `UPLOADS_DIR` | Cartella degli upload |
|
| `UPLOADS_DIR` | Cartella degli upload |
|
||||||
|
| `CAMPUS_DIR` | Cartella dei contenuti del Campus (default `./campus-content`) |
|
||||||
|
|
||||||
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
|
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
|
||||||
|
|
||||||
@@ -124,11 +165,20 @@ Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/in
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/docker/insanitylab/src
|
cd /opt/docker/insanitylab/src
|
||||||
git pull
|
sudo git -c safe.directory="$PWD" pull origin main # il repo è di root: serve sudo, con password
|
||||||
docker compose up -d --build
|
docker compose up -d --build # docker gira senza sudo
|
||||||
```
|
```
|
||||||
|
|
||||||
Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello.
|
Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello. Le migrazioni che cambiano un valore sono sempre guardate dal valore precedente, quindi non sovrascrivono ciò che è stato modificato dal pannello — con la conseguenza, da tenere presente, che un contenuto già ritoccato a mano **non** riceve il nuovo default: va corretto dal pannello.
|
||||||
|
|
||||||
|
Prima di ogni deploy conviene fare il backup del database, che è in modalità WAL: un `cp` del solo `.db` perderebbe le modifiche non ancora consolidate.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 -cmd ".timeout 10000" /opt/docker/insanitylab/data/insanitylab.db ".backup '/dest/insanitylab.db'"
|
||||||
|
sqlite3 /dest/insanitylab.db "PRAGMA integrity_check;" # deve rispondere 'ok'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gli articoli del blog non viaggiano con il codice: per caricarli in produzione, una volta sola, `docker exec insanitylab node scripts/seed-blog-biohacking.mjs`.
|
||||||
|
|
||||||
### Dominio e DNS
|
### Dominio e DNS
|
||||||
|
|
||||||
@@ -149,6 +199,39 @@ Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'av
|
|||||||
|
|
||||||
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
|
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
|
||||||
|
|
||||||
|
**Le guide nuove del Campus danno 404 in sviluppo** — `src/lib/campus.ts` tiene l'indice in cache in memoria: dopo `npm run sync-campus` va riavviato il server.
|
||||||
|
|
||||||
|
**Il database di produzione sembra non aggiornato dopo il deploy** — `sqlite3 -readonly` non legge il file `-wal`, dove stanno le modifiche non ancora consolidate, e mostra valori vecchi. Per verificare, interrogare il database in lettura‑scrittura oppure — meglio — guardare direttamente le pagine del sito.
|
||||||
|
|
||||||
## Licenza
|
## Licenza
|
||||||
|
|
||||||
Proprietà di InsanityLab.
|
Proprietà di InsanityLab.
|
||||||
|
|
||||||
|
## Deploy (dopo la separazione del 03/09/2026 e i due repo del 29/09/2026)
|
||||||
|
|
||||||
|
Dal 29/09/2026 questo repo contiene **solo il sito**, in radice. Le aree riservate hanno il
|
||||||
|
loro repo, `gitea:Adriano/InsanityLab-platforms` (issue #20); fino ad allora le due app erano
|
||||||
|
`apps/sito` e `apps/platforms` dello stesso repo.
|
||||||
|
|
||||||
|
Il progetto compose si chiama **`insanitylab`** (`name:` esplicito nel compose), non piu'
|
||||||
|
`src` come quando la cartella era `/opt/docker/insanitylab/src`.
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /opt/docker/insanitylab/src
|
||||||
|
docker compose build # il sito resta in piedi: non tocca il container
|
||||||
|
docker compose up -d # sostituisce il container
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **`.env` non e' in git** (e non deve esserlo): resta dov'e' ed e' l'unico file che un
|
||||||
|
`git merge` non porta con se'. Il 04/09/2026 lo spostamento sotto `apps/sito/` ha lasciato
|
||||||
|
`.env` alla vecchia radice, l'`up` e' fallito **dopo** che il `down` aveva gia' tolto il
|
||||||
|
container, e il sito e' rimasto giu' **9,7 secondi**. ⚠️ Il 29/09/2026 il sito torna in radice:
|
||||||
|
al primo deploy dopo quel cambio `.env` va spostato a mano da `apps/sito/.env` a `.env`.
|
||||||
|
Prima di ogni swap:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose config >/dev/null # legge env_file e fallisce SUBITO se manca qualcosa
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Un `docker run` sull'immagine **non** e' una prova sufficiente del deploy: non legge
|
||||||
|
`env_file`, quindi passa anche quando il compose fallirebbe.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://insanitylab.it',
|
site: 'https://insanitylab.it',
|
||||||
output: 'server',
|
output: 'server',
|
||||||
|
|
||||||
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
|
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
|
||||||
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
|
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
|
||||||
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
|
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
|
||||||
@@ -15,7 +18,9 @@ export default defineConfig({
|
|||||||
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
|
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
adapter: node({ mode: 'standalone' }),
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
|
||||||
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
|
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
|
||||||
redirects: {
|
redirects: {
|
||||||
'/programs': { status: 301, destination: '/services' },
|
'/programs': { status: 301, destination: '/services' },
|
||||||
@@ -26,4 +31,6 @@ export default defineConfig({
|
|||||||
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
|
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
|
||||||
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
|
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
integrations: [react()],
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
|
# ⚠️ `name:` esplicito. Fino al 03/09/2026 il progetto compose si chiamava `src`, dedotto
|
||||||
|
# dalla cartella /opt/docker/insanitylab/src. Con lo spostamento sotto apps/sito la cartella
|
||||||
|
# cambia nome e il progetto dedotto diventerebbe `sito`: per docker sarebbe un progetto NUOVO,
|
||||||
|
# e il container `insanitylab` gia' in piedi risulterebbe di un altro — con il nome occupato.
|
||||||
|
# Al primo deploy dopo la separazione va fermato il progetto vecchio PRIMA di alzare questo:
|
||||||
|
# docker compose -p src -f <vecchio compose> down
|
||||||
|
# Poi da qui: docker compose up -d --build
|
||||||
|
# Dal 29/09/2026 il sito e' di nuovo in radice (/opt/docker/insanitylab/src): il `name:`
|
||||||
|
# esplicito tiene fermo il progetto `insanitylab` anche a questo cambio di cartella.
|
||||||
|
name: insanitylab
|
||||||
|
|
||||||
services:
|
services:
|
||||||
insanitylab:
|
insanitylab:
|
||||||
build: .
|
build: .
|
||||||
@@ -23,7 +34,12 @@ services:
|
|||||||
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
|
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
|
||||||
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
|
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
|
||||||
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
|
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
|
||||||
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`)
|
# ⚠️ `insanitylab.tielogic.xyz` tolto dal DNS da Adriano il 03/09/2026: il record non
|
||||||
|
# esiste piu'. Lasciarlo qui non e' inerte — il router chiede un certificato per i
|
||||||
|
# nomi della sua regola, e una sfida ACME su un nome che non risolve fallisce a ogni
|
||||||
|
# tentativo, che e' esattamente cio' che Let's Encrypt limita. Il vecchio dominio ha
|
||||||
|
# smesso di essere raggiungibile: da qui in poi vale solo il redirect di `www`.
|
||||||
|
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`)
|
||||||
- traefik.http.routers.insanitylab-redir.tls=true
|
- traefik.http.routers.insanitylab-redir.tls=true
|
||||||
- traefik.http.routers.insanitylab-redir.entrypoints=websecure
|
- traefik.http.routers.insanitylab-redir.entrypoints=websecure
|
||||||
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
|
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Articoli del blog — insanity.lab
|
||||||
|
|
||||||
|
Elenco completo degli articoli presenti sul sito, con categoria, stato di pubblicazione e sintesi.
|
||||||
|
Documento generato il 1 agosto 2026 per la revisione del cliente.
|
||||||
|
|
||||||
|
**Totale:** 20 articoli attivi (20 pubblicati, 0 in bozza).
|
||||||
|
|
||||||
|
## Articoli pubblicati
|
||||||
|
|
||||||
|
### 1. Il sonno si misura in cicli, non in ore
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 31 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/sonno-cicli-non-ore
|
||||||
|
|
||||||
|
Sette ore spezzate valgono meno di sei ore intere. Come funziona l'architettura del sonno e perché conviene ragionare in blocchi da novanta minuti.
|
||||||
|
|
||||||
|
### 2. HRV: che cosa dice davvero la variabilità cardiaca
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 29 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/hrv-variabilita-cardiaca
|
||||||
|
|
||||||
|
Non è la frequenza cardiaca e non è un voto sulla forma fisica. È la misura di quanto il sistema nervoso sa attivarsi e, soprattutto, tornare in equilibrio.
|
||||||
|
|
||||||
|
### 3. Quante proteine servono davvero
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 27 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/quante-proteine-servono
|
||||||
|
|
||||||
|
Prima la quantità, poi la qualità, solo alla fine distribuzione e timing. La gerarchia che conta, con i numeri per chi si allena e per chi no.
|
||||||
|
|
||||||
|
### 4. Creatina: che cosa dice la ricerca
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 25 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/creatina-cosa-dice-la-ricerca
|
||||||
|
|
||||||
|
È l'integratore più studiato in assoluto e uno dei pochi con prove solide. A cosa serve, quanta prenderne e perché non è una sostanza dopante.
|
||||||
|
|
||||||
|
### 5. Il freddo e l'ormesi: perché un piccolo stress fa bene
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 23 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/freddo-ormesi
|
||||||
|
|
||||||
|
Vasi sanguigni, grasso bruno, noradrenalina. Che cosa accade nell'organismo durante l'esposizione al freddo e dove sta il confine con l'esagerazione.
|
||||||
|
|
||||||
|
### 6. Respirare meno per ossigenare meglio: il ruolo della CO2
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 21 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/respirazione-anidride-carbonica
|
||||||
|
|
||||||
|
Non è l'ossigeno a mancare, ma l'anidride carbonica a servire. Come funziona lo scambio nei tessuti e perché la respirazione nasale cambia le cose.
|
||||||
|
|
||||||
|
### 7. La luce che regola l'orologio biologico
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 19 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/luce-orologio-biologico
|
||||||
|
|
||||||
|
Un segnale nella luce del mattino fa partire la giornata metabolica. E gli schermi della sera raccontano al cervello che è ancora mezzogiorno.
|
||||||
|
|
||||||
|
### 8. Recupero attivo: perché fermarsi non basta
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 17 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/recupero-attivo
|
||||||
|
|
||||||
|
Il divano è riposo, non recupero. Sistema linfatico, tessuto connettivo e la differenza fra togliere il carico e favorire la rigenerazione.
|
||||||
|
|
||||||
|
### 9. VO2max: l'indicatore che parla di longevità
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 15 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/vo2max-longevita
|
||||||
|
|
||||||
|
Quanta energia sai erogare nel tempo. È fra i parametri più correlati alla salute futura, e si allena a qualsiasi età.
|
||||||
|
|
||||||
|
### 10. Leggere i dati di orologi e anelli senza farsi ingannare
|
||||||
|
|
||||||
|
- **Categoria:** Articoli tecnici & scientifici
|
||||||
|
- **Data:** 13 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/leggere-i-dati-dei-dispositivi
|
||||||
|
|
||||||
|
Un numero isolato non dice quasi nulla. Le tre regole per trasformare i dati di un dispositivo indossabile in decisioni sensate.
|
||||||
|
|
||||||
|
### 11. Sette abitudini per dormire meglio da stasera
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 11 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/dormire-meglio-da-stasera
|
||||||
|
|
||||||
|
Nessun integratore, nessuna spesa. Piccoli gesti che spostano davvero la qualità del riposo, da provare uno alla volta.
|
||||||
|
|
||||||
|
### 12. Muoversi ogni giorno: quanto basta davvero
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 09 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/muoversi-ogni-giorno
|
||||||
|
|
||||||
|
Fra il divano e la maratona c'è tutto lo spazio che serve. Come mettere insieme una settimana di movimento sostenibile, anche partendo da zero.
|
||||||
|
|
||||||
|
### 13. Il peso non si decide sulla bilancia
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 07 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/il-peso-non-e-solo-la-bilancia
|
||||||
|
|
||||||
|
Cinque pilastri in ordine di importanza, e perché il lavoro vero avviene mentre dormiamo.
|
||||||
|
|
||||||
|
### 14. Proteine a tavola: come distribuirle nella giornata
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 05 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/proteine-a-tavola
|
||||||
|
|
||||||
|
Senza bilancia e senza calcoli complicati. Quantità pratiche, fonti quotidiane e l'errore più comune: concentrare tutto a cena.
|
||||||
|
|
||||||
|
### 15. Doccia fredda: come iniziare senza esagerare
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 03 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/doccia-fredda-come-iniziare
|
||||||
|
|
||||||
|
Una progressione in quattro settimane per abituarsi al freddo, con le avvertenze da tenere presenti prima di cominciare.
|
||||||
|
|
||||||
|
### 16. Cinque minuti di respiro al giorno
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 01 luglio 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/cinque-minuti-di-respiro
|
||||||
|
|
||||||
|
Un esercizio semplice da fare ovunque per abbassare la tensione, e come usarlo prima di dormire o prima di una situazione difficile.
|
||||||
|
|
||||||
|
### 17. La luce del mattino è la sveglia naturale
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 29 giugno 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/luce-del-mattino
|
||||||
|
|
||||||
|
Dieci minuti all'aperto appena alzati cambiano il sonno della notte successiva. E la sera, qualche accorgimento con gli schermi.
|
||||||
|
|
||||||
|
### 18. Come capire quando serve fermarsi
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 27 giugno 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/quando-fermarsi
|
||||||
|
|
||||||
|
I segnali che distinguono la stanchezza normale da quella che chiede una pausa, e cosa fare nei giorni di scarico.
|
||||||
|
|
||||||
|
### 19. Il piatto della longevità
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 25 giugno 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/il-piatto-della-longevita
|
||||||
|
|
||||||
|
Un modello visivo al posto delle tabelle: metà verdure, un quarto carboidrati, un quarto proteine. Semplice da ricordare, difficile da sbagliare.
|
||||||
|
|
||||||
|
### 20. Tenere il cervello in allenamento
|
||||||
|
|
||||||
|
- **Categoria:** Contenuti educativi
|
||||||
|
- **Data:** 23 giugno 2026
|
||||||
|
- **Link:** https://insanitylab.it/blog/cervello-in-allenamento
|
||||||
|
|
||||||
|
Movimento, sonno, relazioni e cose nuove da imparare: le abitudini che proteggono la mente invecchiano meglio di qualsiasi integratore.
|
||||||
|
|
||||||
@@ -9,20 +9,28 @@
|
|||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"start": "node --env-file=.env ./dist/server/entry.mjs",
|
"start": "node --env-file=.env ./dist/server/entry.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"create-user": "node scripts/create-user.mjs"
|
"create-user": "node scripts/create-user.mjs",
|
||||||
|
"seed-blog": "node scripts/seed-blog-biohacking.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.0.1",
|
"@astrojs/node": "^11.0.1",
|
||||||
|
"@astrojs/react": "^6.0.2",
|
||||||
"@fontsource/montserrat": "^5.2.8",
|
"@fontsource/montserrat": "^5.2.8",
|
||||||
"@fontsource/open-sans": "^5.2.7",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@tiptap/core": "^3.27.1",
|
"@tiptap/core": "^3.27.1",
|
||||||
"@tiptap/extension-image": "^3.27.1",
|
"@tiptap/extension-image": "^3.27.1",
|
||||||
"@tiptap/extension-link": "^3.27.1",
|
"@tiptap/extension-link": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
"astro": "^7.0.5",
|
"astro": "^7.0.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"sanitize-html": "^2.17.5"
|
"sanitize-html": "^2.17.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -30,6 +38,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 242 KiB |
@@ -8,8 +8,10 @@ if (!username || !password) {
|
|||||||
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!['admin', 'superuser', 'user'].includes(role)) {
|
// Lista duplicata di proposito: questo file è JavaScript e non può importare da
|
||||||
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
|
// src/lib/auth.ts. La fonte vera è ROLES in src/lib/auth.ts - se cambia là, va allineata qui.
|
||||||
|
if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) {
|
||||||
|
console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera la locandina dell'In-Sanity Hour in PDF vettoriale, con il QR code vero.
|
||||||
|
|
||||||
|
Perché non si parte da un sorgente grafico: l'unico file disponibile è un JPEG da
|
||||||
|
612x751 px, che stampato su A4 darebbe 70 dpi. La locandina però è fatta di tre soli
|
||||||
|
colori piatti, quindi si può ricalcare: ogni forma — testi, calligrafia, bicchieri —
|
||||||
|
diventa un tracciato vettoriale che si stampa nitido a qualsiasi formato, senza dover
|
||||||
|
reinstallare i font originali (che non abbiamo) né riprodurli a occhio.
|
||||||
|
|
||||||
|
Del JPEG resta solo la geometria. Il QR di prova viene coperto e ridisegnato con
|
||||||
|
l'URL vero della pagina evento, anch'esso vettoriale.
|
||||||
|
|
||||||
|
Uso: python3 scripts/locandina-evento.py
|
||||||
|
Serve: pip install --user potracer qrcode cairosvg
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cairosvg
|
||||||
|
import numpy as np
|
||||||
|
import potrace
|
||||||
|
import qrcode
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
URL_EVENTO = 'https://insanitylab.it/promo-eventi/eventi'
|
||||||
|
|
||||||
|
RADICE = Path(__file__).resolve().parent.parent
|
||||||
|
SORGENTE = RADICE.parent / 'Scaricati' / 'Locandina.jpeg'
|
||||||
|
USCITA_PDF = RADICE / 'public' / 'locandina-insanity-hour.pdf'
|
||||||
|
USCITA_PNG = RADICE / 'public' / 'img' / 'locandina-insanity-hour.png'
|
||||||
|
USCITA_TITOLO = RADICE / 'public' / 'img' / 'insanity-hour-titolo.png'
|
||||||
|
|
||||||
|
# Riquadro stampato dentro il JPEG, senza il bordo bianco dello screenshot.
|
||||||
|
RIQUADRO = (22, 13, 597, 732)
|
||||||
|
# Blocco "SAVE THE DATE / IN-SANITY / Hour" usato come immagine nella pagina web.
|
||||||
|
TITOLO_WEB = (23, 24, 596, 275)
|
||||||
|
# Quadrato del QR di prova da sostituire, in coordinate del JPEG originale.
|
||||||
|
QR_PROVA = (387, 632, 475, 720)
|
||||||
|
# Bordo superiore della fascia beige: la toppa sul QR non deve sconfinare nel marrone.
|
||||||
|
FASCIA_Y = 627
|
||||||
|
|
||||||
|
FONDO, BEIGE, CREMA = '#4F3126', '#D0B49E', '#F6F1E9'
|
||||||
|
CENTRI = [(79, 49, 38), (208, 180, 158), (246, 241, 233)]
|
||||||
|
UPSCALE = 4 # si traccia sull'immagine ingrandita: bordi piu' morbidi, curve piu' fedeli
|
||||||
|
MODULI_QUIET = 4 # margine muto richiesto dallo standard QR, in moduli
|
||||||
|
|
||||||
|
|
||||||
|
def classifica(immagine: Image.Image) -> np.ndarray:
|
||||||
|
"""Assegna a ogni pixel il piu' vicino dei tre colori piatti della locandina."""
|
||||||
|
grande = immagine.resize((immagine.width * UPSCALE, immagine.height * UPSCALE), Image.LANCZOS)
|
||||||
|
px = np.asarray(grande).astype(np.int16)
|
||||||
|
distanze = np.stack([((px - np.array(c)) ** 2).sum(axis=2) for c in CENTRI])
|
||||||
|
return np.argmin(distanze, axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
def traccia(maschera: np.ndarray) -> str:
|
||||||
|
"""Contorni della maschera come path SVG. potracer prende per foreground i pixel
|
||||||
|
falsi, quindi la maschera va negata."""
|
||||||
|
percorso = potrace.Bitmap(~maschera).trace(turdsize=6, alphamax=1.0, opticurve=True, opttolerance=0.2)
|
||||||
|
d = []
|
||||||
|
for curva in percorso.curves:
|
||||||
|
avvio = curva.start_point
|
||||||
|
d.append(f'M{avvio.x / UPSCALE:.2f},{avvio.y / UPSCALE:.2f}')
|
||||||
|
for seg in curva.segments:
|
||||||
|
fine = seg.end_point
|
||||||
|
if seg.is_corner:
|
||||||
|
d.append(f'L{seg.c.x / UPSCALE:.2f},{seg.c.y / UPSCALE:.2f}L{fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}')
|
||||||
|
else:
|
||||||
|
d.append(
|
||||||
|
f'C{seg.c1.x / UPSCALE:.2f},{seg.c1.y / UPSCALE:.2f}'
|
||||||
|
f' {seg.c2.x / UPSCALE:.2f},{seg.c2.y / UPSCALE:.2f}'
|
||||||
|
f' {fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}'
|
||||||
|
)
|
||||||
|
d.append('Z')
|
||||||
|
return ''.join(d)
|
||||||
|
|
||||||
|
|
||||||
|
def qr_svg(x0: float, y0: float, x1: float, y1: float) -> str:
|
||||||
|
"""QR dell'URL evento, disegnato a rettangoli dentro il riquadro dato.
|
||||||
|
|
||||||
|
Il QR di prova sfiorava il bordo marrone: qui il modulo viene calcolato includendo
|
||||||
|
il margine muto, cosi' il codice resta leggibile anche stampato in piccolo.
|
||||||
|
"""
|
||||||
|
codice = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=1, border=0)
|
||||||
|
codice.add_data(URL_EVENTO)
|
||||||
|
codice.make(fit=True)
|
||||||
|
matrice = codice.get_matrix()
|
||||||
|
n = len(matrice)
|
||||||
|
|
||||||
|
lato = min(x1 - x0, y1 - y0)
|
||||||
|
modulo = lato / (n + 2 * MODULI_QUIET)
|
||||||
|
off_x = x0 + (x1 - x0 - lato) / 2 + MODULI_QUIET * modulo
|
||||||
|
off_y = y0 + (y1 - y0 - lato) / 2 + MODULI_QUIET * modulo
|
||||||
|
|
||||||
|
quadrati = [
|
||||||
|
f'<rect x="{off_x + c * modulo:.3f}" y="{off_y + r * modulo:.3f}" '
|
||||||
|
f'width="{modulo:.3f}" height="{modulo:.3f}"/>'
|
||||||
|
for r, riga in enumerate(matrice)
|
||||||
|
for c, pieno in enumerate(riga)
|
||||||
|
if pieno
|
||||||
|
]
|
||||||
|
return f'<g fill="{FONDO}" shape-rendering="crispEdges">{"".join(quadrati)}</g>', n
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
originale = Image.open(SORGENTE).convert('RGB')
|
||||||
|
locandina = originale.crop(RIQUADRO)
|
||||||
|
larghezza, altezza = locandina.size
|
||||||
|
etichette = classifica(locandina)
|
||||||
|
|
||||||
|
dx, dy = RIQUADRO[0], RIQUADRO[1]
|
||||||
|
qx0, qy0, qx1, qy1 = QR_PROVA[0] - dx, QR_PROVA[1] - dy, QR_PROVA[2] - dx, QR_PROVA[3] - dy
|
||||||
|
qr, moduli = qr_svg(qx0, qy0, qx1, qy1)
|
||||||
|
toppa_y = max(qy0 - 6, FASCIA_Y - dy + 1)
|
||||||
|
|
||||||
|
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {larghezza} {altezza}" width="{larghezza}" height="{altezza}">
|
||||||
|
<rect width="{larghezza}" height="{altezza}" fill="{FONDO}"/>
|
||||||
|
<path fill="{BEIGE}" d="{traccia(etichette == 1)}"/>
|
||||||
|
<path fill="{CREMA}" d="{traccia(etichette == 2)}"/>
|
||||||
|
<rect x="{qx0 - 6}" y="{toppa_y}" width="{qx1 - qx0 + 12}" height="{qy1 + 6 - toppa_y}" fill="{BEIGE}"/>
|
||||||
|
{qr}
|
||||||
|
</svg>'''
|
||||||
|
|
||||||
|
USCITA_PDF.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Proporzioni native (575x719). cairosvg legge i px a 96 dpi: per una base di 210 mm
|
||||||
|
# (la larghezza di un A4) la pagina va chiesta in px, non in punti.
|
||||||
|
px_per_mm = 96 / 25.4
|
||||||
|
scala = 210 * px_per_mm / larghezza
|
||||||
|
cairosvg.svg2pdf(bytestring=svg.encode(), write_to=str(USCITA_PDF),
|
||||||
|
output_width=larghezza * scala, output_height=altezza * scala)
|
||||||
|
cairosvg.svg2png(bytestring=svg.encode(), write_to=str(USCITA_PNG),
|
||||||
|
output_width=larghezza * 3, output_height=altezza * 3)
|
||||||
|
originale.crop(TITOLO_WEB).save(USCITA_TITOLO)
|
||||||
|
|
||||||
|
print(f'locandina {larghezza}x{altezza} px -> {USCITA_PDF.relative_to(RADICE)} '
|
||||||
|
f'({210:.0f}x{210 * altezza / larghezza:.0f} mm)')
|
||||||
|
print(f'anteprima {larghezza * 3}x{altezza * 3} -> {USCITA_PNG.relative_to(RADICE)}')
|
||||||
|
print(f'QR {moduli}x{moduli} moduli -> {URL_EVENTO}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* seed-blog-biohacking.mjs — carica nel blog venti articoli divulgativi tratti dal corso
|
||||||
|
* base del Biohacking Campus: dieci tecnici a firma Nicola Antonelli e dieci educativi a
|
||||||
|
* firma Donata Dal Pastro.
|
||||||
|
*
|
||||||
|
* PERCHÉ UNO SCRIPT
|
||||||
|
* Gli articoli vivono nel database, che in produzione è un volume del VPS e non viene
|
||||||
|
* toccato dal deploy: creandoli a mano in locale resterebbero in locale. Questo script è
|
||||||
|
* versionato, quindi si esegue anche là.
|
||||||
|
*
|
||||||
|
* USO
|
||||||
|
* npm run seed-blog (usa data/insanitylab.db, o DB_PATH se impostata)
|
||||||
|
*
|
||||||
|
* IDEMPOTENTE
|
||||||
|
* Salta gli articoli il cui slug è già presente e non tocca quelli modificati a mano.
|
||||||
|
* Gli autori vengono creati solo se mancano, con una password casuale: per usare quegli
|
||||||
|
* account si passa dal pannello Utenti e si imposta una password.
|
||||||
|
*/
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||||
|
const TECNICI = 'Articoli tecnici & scientifici';
|
||||||
|
const EDUCATIVI = 'Contenuti educativi';
|
||||||
|
const NICOLA = 'Nicola Antonelli';
|
||||||
|
const DONATA = 'Donata Dal Pastro';
|
||||||
|
|
||||||
|
const p = (...paragrafi) => paragrafi.join('\n');
|
||||||
|
|
||||||
|
const articoli = [
|
||||||
|
// ---------------------------------------------------------------- tecnici
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'sonno-cicli-non-ore',
|
||||||
|
title: 'Il sonno si misura in cicli, non in ore',
|
||||||
|
excerpt: 'Sette ore spezzate valgono meno di sei ore intere. Come funziona l\'architettura del sonno e perché conviene ragionare in blocchi da novanta minuti.',
|
||||||
|
body: p(
|
||||||
|
'<p>Quando parliamo di sonno, quasi tutti pensano a un numero: sette ore, otto ore. È una misura comoda ma imprecisa, perché il sonno non è un blocco uniforme. È una sequenza di cicli, ciascuno della durata media di novanta minuti, in cui si alternano fasi leggere, sonno profondo e fase REM.</p>',
|
||||||
|
'<p>Ogni fase ha un compito diverso. Nel sonno profondo il corpo ripara i tessuti e libera l\'ormone della crescita; nella fase REM il cervello riordina memoria ed emozioni. Interrompere un ciclo a metà significa svegliarsi nel mezzo di un lavoro non finito: è la ragione per cui a volte ci si alza più stanchi dopo otto ore che dopo sei e mezza.</p>',
|
||||||
|
'<h2>Quattro o cinque cicli, non un numero fisso</h2>',
|
||||||
|
'<p>Per un adulto l\'obiettivo ragionevole sono quattro o cinque cicli completi, cioè fra le sei e le sette ore e mezza. Contare i cicli invece delle ore cambia anche il modo di scegliere l\'orario della sveglia: meglio anticiparla di venti minuti per chiudere un ciclo, che rubarne dieci nel mezzo del successivo.</p>',
|
||||||
|
'<h2>I micro-risvegli non sono un problema</h2>',
|
||||||
|
'<p>Durante la notte ci si sveglia molte più volte di quanto si ricordi: un centinaio di micro-risvegli è considerato normale e non compromette la qualità del riposo. Sono la loro frequenza eccessiva e la difficoltà a riaddormentarsi a segnalare un sonno realmente disturbato, non il singolo risveglio notturno che tutti sperimentiamo.</p>',
|
||||||
|
'<h2>La notte si prepara di giorno</h2>',
|
||||||
|
'<p>Il punto meno intuitivo è che la qualità del sonno si decide molto prima di andare a letto. Il rilascio serale di melatonina segue un conto alla rovescia che parte dal momento in cui ci si sveglia e si prende la prima luce del giorno; il cortisolo, il suo antagonista, dovrebbe salire al mattino e scendere la sera. Luce forte a tarda ora, cene abbondanti e allenamenti intensi a ridosso della notte spostano questo equilibrio.</p>',
|
||||||
|
'<p>Per chi si allena la conseguenza è concreta: il lavoro fatto in palestra diventa adattamento durante la notte. Se il sonno è compromesso, lo stimolo c\'è ma la risposta arriva a metà.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'hrv-variabilita-cardiaca',
|
||||||
|
title: 'HRV: che cosa dice davvero la variabilità cardiaca',
|
||||||
|
excerpt: 'Non è la frequenza cardiaca e non è un voto sulla forma fisica. È la misura di quanto il sistema nervoso sa attivarsi e, soprattutto, tornare in equilibrio.',
|
||||||
|
body: p(
|
||||||
|
'<p>Sempre più orologi e anelli mostrano un valore chiamato HRV, variabilità della frequenza cardiaca. Viene spesso letto come una pagella quotidiana, ma misura qualcosa di più sottile: non quanti battiti fa il cuore in un minuto, bensì quanto varia l\'intervallo di tempo fra un battito e il successivo.</p>',
|
||||||
|
'<p>Un cuore sano non è un metronomo. Quella variazione riflette il dialogo continuo fra le due parti del sistema nervoso autonomo: il ramo simpatico, che ci attiva davanti a uno sforzo o a una scadenza, e il parasimpatico, che ci riporta al riposo e alla digestione.</p>',
|
||||||
|
'<h2>Il ritorno all\'equilibrio conta più dell\'attivazione</h2>',
|
||||||
|
'<p>La lettura più utile dell\'HRV non riguarda la capacità di rispondere allo stress, che quasi tutti hanno, ma quella di rientrare in equilibrio quando lo stimolo finisce. Essere in forma significa saper accendere e saper spegnere. Un valore stabilmente basso racconta spesso un sistema che resta acceso anche quando non serve.</p>',
|
||||||
|
'<h2>Il numero da solo non significa nulla</h2>',
|
||||||
|
'<p>L\'HRV ha una fortissima componente individuale: confrontare il proprio valore con quello di un\'altra persona non ha senso, perché dipende dall\'età, dalla genetica e dall\'algoritmo del dispositivo. Ha senso invece confrontarlo con se stessi nel tempo, guardando il trend su settimane e mesi.</p>',
|
||||||
|
'<h2>Come si legge in pratica</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>Un calo isolato dopo un allenamento intenso o una serata storta è fisiologico.</li>',
|
||||||
|
'<li>Un calo che si protrae per giorni, insieme a sonno peggiore e frequenza a riposo più alta, suggerisce di alleggerire il carico.</li>',
|
||||||
|
'<li>Alcol, cena tardi, allenamento serale e febbre incidono più di quanto si immagini.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Il valore aggiunto non è il numero, ma il mettere in relazione più dati: sonno, carico di allenamento, HRV e frequenza a riposo raccontano insieme una storia che nessuno dei quattro racconta da solo.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'quante-proteine-servono',
|
||||||
|
title: 'Quante proteine servono davvero',
|
||||||
|
excerpt: 'Prima la quantità, poi la qualità, solo alla fine distribuzione e timing. La gerarchia che conta, con i numeri per chi si allena e per chi no.',
|
||||||
|
body: p(
|
||||||
|
'<p>Sulle proteine circolano più opinioni che dati. Un modo ordinato di ragionare è pensarle come una piramide a quattro livelli: alla base la quantità totale della giornata, sopra la qualità delle fonti, poi la distribuzione nei pasti e infine il timing rispetto all\'allenamento. Sistemare la base vale la gran parte del risultato; occuparsi del vertice ignorando le fondamenta è come montare le finestre prima dei muri.</p>',
|
||||||
|
'<h2>I numeri di riferimento</h2>',
|
||||||
|
'<p>Per una persona sedentaria il riferimento minimo è intorno a 0,8 grammi per chilo di peso corporeo al giorno. Per chi si allena con regolarità e vuole mantenere o costruire massa muscolare si sale a circa 1,5-1,6 grammi per chilo, con margini fino a 1,8-2 nelle fasi di carico o di dimagrimento.</p>',
|
||||||
|
'<p>Tradotto: una persona di 70 chili che va in palestra tre volte a settimana si colloca intorno ai 105-115 grammi al giorno, non ai 56 del riferimento minimo per chi non si muove.</p>',
|
||||||
|
'<h2>Il muscolo è un organo, non un ornamento</h2>',
|
||||||
|
'<p>La massa muscolare regola il metabolismo, protegge le articolazioni, partecipa all\'equilibrio ormonale e si correla alla capacità di restare autonomi con l\'età. È però un tessuto costoso da mantenere: in assenza di stimolo e di materiale l\'organismo lo smonta. La perdita progressiva di muscolo, la sarcopenia, comincia prima di quanto si creda, già a partire dai venticinque anni, e accelera senza allenamento contro resistenza.</p>',
|
||||||
|
'<h2>Conta il cronico, non il singolo giorno</h2>',
|
||||||
|
'<p>Una giornata sotto la quota non produce alcun danno. È il deficit ripetuto per mesi a portare al catabolismo. Vale anche il contrario: nessun singolo pasto proteico compensa settimane di apporto insufficiente.</p>',
|
||||||
|
'<p>Sulla distribuzione, l\'indicazione pratica è ripartire le proteine su tre o quattro pasti invece di concentrarle a cena, perché la sintesi proteica risponde meglio a stimoli ripetuti. Ma è una rifinitura: ha senso occuparsene quando la quantità totale è già a posto.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'creatina-cosa-dice-la-ricerca',
|
||||||
|
title: 'Creatina: che cosa dice la ricerca',
|
||||||
|
excerpt: 'È l\'integratore più studiato in assoluto e uno dei pochi con prove solide. A cosa serve, quanta prenderne e perché non è una sostanza dopante.',
|
||||||
|
body: p(
|
||||||
|
'<p>La creatina è probabilmente l\'integratore su cui esiste più letteratura scientifica, con decenni di studi alle spalle. Non è un ormone né una sostanza dopante: è un composto che l\'organismo produce e che assumiamo anche con la carne e il pesce, immagazzinato soprattutto nel muscolo.</p>',
|
||||||
|
'<h2>Come funziona</h2>',
|
||||||
|
'<p>Serve a rigenerare rapidamente l\'ATP, la molecola che fornisce energia nelle contrazioni brevi e intense. Avere i depositi pieni significa disporre di qualche ripetizione in più negli sforzi massimali, e quindi accumulare nel tempo un volume di lavoro maggiore. Il beneficio non è immediato: è il risultato di mesi di allenamento leggermente più produttivo.</p>',
|
||||||
|
'<h2>Quanta e quando</h2>',
|
||||||
|
'<p>Il dosaggio più studiato è di circa 3-5 grammi al giorno di creatina monoidrato, tutti i giorni, compresi quelli di riposo. Il momento della giornata è ininfluente: ciò che conta è la saturazione dei depositi, che si raggiunge in qualche settimana di assunzione costante. Le formulazioni più costose non hanno mostrato vantaggi rispetto al monoidrato.</p>',
|
||||||
|
'<h2>Effetti collaterali e falsi miti</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>Il piccolo aumento di peso iniziale è acqua trattenuta dentro il muscolo, non grasso.</li>',
|
||||||
|
'<li>Nelle persone con funzione renale normale non sono emersi danni ai reni alle dosi abituali.</li>',
|
||||||
|
'<li>Non provoca crampi né disidratazione, al contrario di quanto si diceva anni fa.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Un aspetto meno noto riguarda il cervello, che utilizza anch\'esso creatina: alcune ricerche indicano effetti sulla fatica mentale e sulle prestazioni cognitive, in particolare in condizioni di privazione di sonno. Le prove qui sono più recenti e meno solide che sul versante muscolare.</p>',
|
||||||
|
'<p>Come sempre, un integratore lavora sul margine. Con un allenamento discontinuo e proteine insufficienti, la creatina non ha molto su cui agire.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'freddo-ormesi',
|
||||||
|
title: 'Il freddo e l\'ormesi: perché un piccolo stress fa bene',
|
||||||
|
excerpt: 'Vasi sanguigni, grasso bruno, noradrenalina. Che cosa accade nell\'organismo durante l\'esposizione al freddo e dove sta il confine con l\'esagerazione.',
|
||||||
|
body: p(
|
||||||
|
'<p>Alla base dell\'esposizione al freddo c\'è un principio generale della biologia: l\'ormesi. Uno stimolo che a dosi elevate sarebbe dannoso, somministrato a dosi contenute produce l\'effetto opposto e rende l\'organismo più resistente. Vale per l\'esercizio fisico, per il digiuno e appunto per il freddo.</p>',
|
||||||
|
'<h2>Che cosa succede nel corpo</h2>',
|
||||||
|
'<p>Il primo effetto riguarda i vasi sanguigni. I cicli di vasocostrizione e vasodilatazione allenano la muscolatura liscia che li avvolge, migliorando nel tempo la microcircolazione. È un lavoro che il sistema cardiovascolare non riceve in nessun altro modo.</p>',
|
||||||
|
'<p>Il secondo riguarda il tessuto adiposo bruno, ricco di mitocondri, che sotto una certa temperatura brucia energia per produrre calore invece di immagazzinarla. Il terzo è nervoso: l\'esposizione al freddo aumenta in modo marcato la noradrenalina, oltre a dopamina e serotonina, ed è la ragione della lucidità e del buonumore che molti riferiscono dopo.</p>',
|
||||||
|
'<h2>Il disagio è parte del meccanismo</h2>',
|
||||||
|
'<p>Il beneficio non nasce dal piacere ma dalla condizione di stress controllato. Una doccia tiepida non produce gli stessi adattamenti di un\'immersione realmente fredda. Allo stesso tempo, dose e durata contano: qui più non è meglio.</p>',
|
||||||
|
'<h2>Le cautele che vale la pena conoscere</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>L\'<em>afterdrop</em> è il calo della temperatura interna che avviene <strong>dopo</strong> l\'uscita dall\'acqua, non durante: è il momento più delicato, e va gestito riscaldandosi gradualmente.</li>',
|
||||||
|
'<li>Chi ha patologie cardiovascolari o pressorie dovrebbe parlarne prima con il proprio medico.</li>',
|
||||||
|
'<li>Il freddo subito dopo un allenamento di forza smorza parte della risposta infiammatoria che serve all\'adattamento muscolare: meglio distanziarlo di qualche ora.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Vale la pena ricordare che l\'infiammazione acuta è un processo utile e necessario. L\'obiettivo non è spegnerla, ma contenere quella cronica di basso grado che accompagna sedentarietà, sonno scarso e stress prolungato.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'respirazione-anidride-carbonica',
|
||||||
|
title: 'Respirare meno per ossigenare meglio: il ruolo della CO2',
|
||||||
|
excerpt: 'Non è l\'ossigeno a mancare, ma l\'anidride carbonica a servire. Come funziona lo scambio nei tessuti e perché la respirazione nasale cambia le cose.',
|
||||||
|
body: p(
|
||||||
|
'<p>Respiriamo più di ventimila volte al giorno senza pensarci. È proprio l\'automatismo a rendere poco intuitivo il meccanismo: quasi tutti immaginano che respirare di più significhi ossigenarsi meglio. In condizioni normali il sangue arterioso è già saturo di ossigeno quasi al massimo, e il fattore limitante è un altro.</p>',
|
||||||
|
'<h2>La CO2 non è solo uno scarto</h2>',
|
||||||
|
'<p>L\'anidride carbonica regola il rilascio dell\'ossigeno dall\'emoglobina ai tessuti. Con livelli di CO2 troppo bassi, l\'ossigeno resta legato ai globuli rossi e arriva meno dove serve. Iperventilare, cioè respirare più del necessario, produce quindi l\'effetto opposto a quello desiderato.</p>',
|
||||||
|
'<p>Da qui l\'utilità di allenare la tolleranza alla CO2 con respirazioni lente e ritenzioni brevi: non si tratta di prendere più aria, ma di usarne meglio quella che entra.</p>',
|
||||||
|
'<h2>Il respiro come interruttore del sistema nervoso</h2>',
|
||||||
|
'<p>Il respiro è l\'unica funzione autonoma che possiamo controllare volontariamente, e questo lo rende una leva diretta sul sistema nervoso. Accelerare il ritmo attiva il ramo simpatico, quello dell\'allerta; rallentare e soprattutto allungare l\'espirazione attiva il parasimpatico, quello del recupero. È il motivo per cui un\'espirazione più lunga dell\'inspirazione abbassa la frequenza cardiaca in pochi minuti.</p>',
|
||||||
|
'<h2>Perché dal naso</h2>',
|
||||||
|
'<p>La respirazione nasale filtra, riscalda e umidifica l\'aria, e produce ossido nitrico, che favorisce la vasodilatazione. Respirare dalla bocca a riposo, soprattutto di notte, è associato a sonno più frammentato e secchezza delle vie aeree.</p>',
|
||||||
|
'<p>Per chi si allena, la respirazione nasale nei lavori a bassa intensità è un allenamento in sé: all\'inizio costringe a rallentare, poi migliora l\'efficienza. Nelle intensità elevate la bocca resta necessaria, ed è del tutto normale.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'luce-orologio-biologico',
|
||||||
|
title: 'La luce che regola l\'orologio biologico',
|
||||||
|
excerpt: 'Un segnale nella luce del mattino fa partire la giornata metabolica. E gli schermi della sera raccontano al cervello che è ancora mezzogiorno.',
|
||||||
|
body: p(
|
||||||
|
'<p>Ci siamo evoluti per milioni di anni sotto il sole, e i nostri processi biologici sono sincronizzati sul ciclo luce-buio. La luce non è un contorno ambientale: è il segnale principale che coordina l\'orologio interno.</p>',
|
||||||
|
'<h2>Il segnale del mattino</h2>',
|
||||||
|
'<p>Nella luce dell\'alba è presente una componente blu che, attraverso l\'occhio, raggiunge il nucleo soprachiasmatico, l\'orologio centrale situato nell\'ipotalamo. Da lì parte a cascata la giornata metabolica: risveglio, produzione di cortisolo, attivazione della digestione e della muscolatura. È anche il momento in cui viene impostato il conto alla rovescia per la melatonina serale.</p>',
|
||||||
|
'<p>In pratica: dieci o quindici minuti all\'aperto entro la prima ora dal risveglio hanno un effetto che nessuna lampada da interno riesce a replicare, perché l\'intensità luminosa esterna è di ordini di grandezza superiore anche in una giornata nuvolosa.</p>',
|
||||||
|
'<h2>Il problema della sera</h2>',
|
||||||
|
'<p>I LED di lampade, televisori e telefoni emettono lunghezze d\'onda simili a quelle della luce diurna. Usarli a tarda ora comunica al cervello che è ancora giorno, tenendo alto lo stato di allerta e ritardando la melatonina — che, oltre a regolare il sonno profondo, è uno dei più potenti antiossidanti prodotti dall\'organismo.</p>',
|
||||||
|
'<h2>Anche la pelle riceve luce</h2>',
|
||||||
|
'<p>Non sono solo gli occhi a raccogliere il segnale: la pelle contiene fotorecettori e partecipa alla regolazione dello stato di veglia. È una delle ragioni per cui l\'esposizione all\'aperto ha effetti che una luce artificiale puntata sugli occhi non riproduce.</p>',
|
||||||
|
'<p>Un ultimo punto, spesso frainteso: addormentarsi in pochi secondi non è segno di buon sonno. Più spesso indica un debito accumulato. Un addormentamento in dieci-venti minuti è considerato fisiologico.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'recupero-attivo',
|
||||||
|
title: 'Recupero attivo: perché fermarsi non basta',
|
||||||
|
excerpt: 'Il divano è riposo, non recupero. Sistema linfatico, tessuto connettivo e la differenza fra togliere il carico e favorire la rigenerazione.',
|
||||||
|
body: p(
|
||||||
|
'<p>Il recupero viene quasi sempre inteso in negativo: non allenarsi, non fare nulla. È una lettura parziale. Il riposo passivo serve, ma la rigenerazione dei tessuti si accelera con il movimento, non con l\'immobilità.</p>',
|
||||||
|
'<h2>Il sistema linfatico non ha una pompa</h2>',
|
||||||
|
'<p>A differenza del sangue, che ha il cuore, la linfa si muove grazie alla contrazione muscolare e alla respirazione. Restare fermi rallenta il drenaggio dei cataboliti prodotti dall\'allenamento; una camminata, una pedalata leggera o una sessione di mobilità lo favoriscono. È la logica del recupero attivo.</p>',
|
||||||
|
'<h2>La fascia va tenuta fluida</h2>',
|
||||||
|
'<p>Il tessuto connettivo che avvolge muscoli e organi è ricco di acido ialuronico, una molecola sensibile alla temperatura che tende a irrigidirsi con il freddo e la sedentarietà. Movimento a bassa intensità, calore e lavoro sui tessuti aiutano a mantenerlo elastico, con effetti diretti sulla percezione di rigidità del giorno dopo.</p>',
|
||||||
|
'<h2>Carico e recupero sono due fasi della stessa cosa</h2>',
|
||||||
|
'<p>Il progresso non nasce dall\'allenamento ma dall\'adattamento all\'allenamento, che avviene dopo. Quando l\'energia spesa supera stabilmente quella rigenerata, il risultato non è più allenamento: è affaticamento cronico. Per questo il recupero andrebbe programmato nella settimana come si programma una seduta, non lasciato ai ritagli.</p>',
|
||||||
|
'<h2>Attenzione a non assistere troppo il corpo</h2>',
|
||||||
|
'<p>C\'è un limite alla logica dell\'aiuto esterno. Riempire l\'organismo di antiossidanti e integratori attorno all\'allenamento rischia di smorzare proprio le risposte che l\'allenamento dovrebbe stimolare. Il corpo ha bisogno di essere sollecitato e poi messo nelle condizioni di rispondere, non di essere protetto da ogni stimolo.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'vo2max-longevita',
|
||||||
|
title: 'VO2max: l\'indicatore che parla di longevità',
|
||||||
|
excerpt: 'Quanta energia sai erogare nel tempo. È fra i parametri più correlati alla salute futura, e si allena a qualsiasi età.',
|
||||||
|
body: p(
|
||||||
|
'<p>Il VO2max misura il massimo volume di ossigeno che l\'organismo riesce a utilizzare nell\'unità di tempo. Detto in modo più diretto: quanta energia si è capaci di erogare in modo sostenuto. È un indicatore nato nel mondo della prestazione sportiva, ma oggi viene considerato uno dei parametri più informativi sullo stato di salute complessivo.</p>',
|
||||||
|
'<h2>Perché conta anche per chi non gareggia</h2>',
|
||||||
|
'<p>Un VO2max più alto significa un margine più ampio fra ciò che la vita quotidiana richiede e ciò che l\'organismo può erogare. Quel margine è ciò che permette di salire le scale con la spesa a sessant\'anni, e di reggere meglio una malattia o un intervento.</p>',
|
||||||
|
'<h2>Si allena, e non solo correndo</h2>',
|
||||||
|
'<p>Migliora con il lavoro cardiovascolare strutturato: molto volume a bassa intensità, dove si respira ancora dal naso e si riesce a parlare, più una quota limitata di lavori intensi. La combinazione conta più della singola seduta dura, e i risultati arrivano con cicli di carico e scarico, non con settimane tutte uguali.</p>',
|
||||||
|
'<h2>La palestra non è uno sport</h2>',
|
||||||
|
'<p>È un\'osservazione utile a smontare un pregiudizio frequente. La sala pesi non è una disciplina a sé che produce solo muscoli visibili: è il lavoro complementare di cui hanno bisogno il corridore, il tennista, il nuotatore e chiunque voglia arrivare lontano in salute. Forza e capacità aerobica non sono alternative, sono due gambe dello stesso percorso.</p>',
|
||||||
|
'<p>Gli orologi sportivi stimano il VO2max con formule indirette: il valore assoluto va preso con prudenza, la sua tendenza nel tempo è invece un\'informazione utile.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: NICOLA, categoria: TECNICI,
|
||||||
|
slug: 'leggere-i-dati-dei-dispositivi',
|
||||||
|
title: 'Leggere i dati di orologi e anelli senza farsi ingannare',
|
||||||
|
excerpt: 'Un numero isolato non dice quasi nulla. Le tre regole per trasformare i dati di un dispositivo indossabile in decisioni sensate.',
|
||||||
|
body: p(
|
||||||
|
'<p>Un dispositivo indossabile produce ogni giorno decine di valori: sonno, recupero, sforzo, prontezza, frequenza a riposo, variabilità cardiaca. Il rischio è passare dal non sapere nulla al sapere troppo, senza che questo migliori una sola decisione.</p>',
|
||||||
|
'<h2>Prima regola: guardare il trend, non il giorno</h2>',
|
||||||
|
'<p>Un valore isolato è rumore. Il punteggio di recupero basso del martedì non significa nulla da solo; la stessa metrica in calo per otto giorni consecutivi è un\'informazione. Ha senso ragionare su finestre di una settimana, un mese, un trimestre.</p>',
|
||||||
|
'<h2>Seconda regola: incrociare i parametri</h2>',
|
||||||
|
'<p>Il valore aggiunto sta nel mettere in relazione i dati fra loro. Una variabilità cardiaca crollata si spiega spesso con un allenamento serale, una cena tardiva o un bicchiere di troppo. Presa da sola diventa un allarme; letta insieme al resto diventa una spiegazione.</p>',
|
||||||
|
'<h2>Terza regola: sapere che cosa misura il proprio dispositivo</h2>',
|
||||||
|
'<p>Gli strumenti non sono equivalenti. Alcuni nascono orientati allo sport e pesano molto il carico di allenamento; altri nascono come strumenti per il sonno e restituiscono un quadro più orientato allo stile di vita. Misurano grandezze simili, ma le elaborano con algoritmi diversi: confrontare il proprio numero con quello di un amico che usa un altro dispositivo non ha alcun significato.</p>',
|
||||||
|
'<h2>E il corpo?</h2>',
|
||||||
|
'<p>Il dato non sostituisce la sensazione, la contestualizza. Se il dispositivo segnala recupero pieno ma ci si sente a pezzi, l\'informazione utile è la seconda. Gli strumenti servono a rendere visibili le tendenze che la percezione quotidiana fatica a cogliere, non a scavalcarla.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- educativi
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'dormire-meglio-da-stasera',
|
||||||
|
title: 'Sette abitudini per dormire meglio da stasera',
|
||||||
|
excerpt: 'Nessun integratore, nessuna spesa. Piccoli gesti che spostano davvero la qualità del riposo, da provare uno alla volta.',
|
||||||
|
body: p(
|
||||||
|
'<p>Dormire bene è la cosa più economica che possiamo fare per la nostra salute, e spesso la più trascurata. Non servono acquisti: serve preparare la notte durante il giorno. Ecco sette abitudini, in ordine di facilità.</p>',
|
||||||
|
'<h2>1. Uscire la mattina</h2>',
|
||||||
|
'<p>Dieci minuti di luce naturale entro un\'ora dal risveglio danno il via all\'orologio interno e, di conseguenza, alla melatonina della sera. Anche con il cielo coperto.</p>',
|
||||||
|
'<h2>2. Andare a letto e alzarsi a orari simili</h2>',
|
||||||
|
'<p>La regolarità conta più della durata. Un orario stabile, weekend compresi, vale più di un\'ora in più presa a casaccio.</p>',
|
||||||
|
'<h2>3. Cenare prima e più leggeri</h2>',
|
||||||
|
'<p>Una digestione in corso alza la temperatura interna e disturba le prime ore di sonno, quelle più profonde. Meglio finire almeno due o tre ore prima di coricarsi.</p>',
|
||||||
|
'<h2>4. Abbassare le luci dopo cena</h2>',
|
||||||
|
'<p>Non serve vivere a lume di candela: basta spegnere il lampadario centrale e usare luci basse e calde. Il cervello legge la penombra come segnale di sera.</p>',
|
||||||
|
'<h2>5. Fermare il caffè presto</h2>',
|
||||||
|
'<p>La caffeina resta in circolo per molte ore: il caffè delle cinque del pomeriggio è ancora presente a mezzanotte. Per la maggior parte delle persone conviene chiudere all\'ora di pranzo.</p>',
|
||||||
|
'<h2>6. Tenere la camera fresca e buia</h2>',
|
||||||
|
'<p>La temperatura interna deve scendere perché il sonno profondo arrivi: una stanza attorno ai 18 gradi aiuta. Buio significa buio davvero, anche le lucine degli apparecchi.</p>',
|
||||||
|
'<h2>7. Costruire un piccolo rituale</h2>',
|
||||||
|
'<p>Sempre gli stessi gesti nella mezz\'ora prima di dormire — una doccia, qualche pagina, due minuti di respiro lento — insegnano al corpo che cosa sta per succedere.</p>',
|
||||||
|
'<p>Un consiglio pratico: non provarle tutte insieme. Se ne scelgono due, si tengono per dieci giorni e si valuta come ci si sente al risveglio.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'muoversi-ogni-giorno',
|
||||||
|
title: 'Muoversi ogni giorno: quanto basta davvero',
|
||||||
|
excerpt: 'Fra il divano e la maratona c\'è tutto lo spazio che serve. Come mettere insieme una settimana di movimento sostenibile, anche partendo da zero.',
|
||||||
|
body: p(
|
||||||
|
'<p>La domanda che arriva più spesso è: quanto devo allenarmi? La risposta onesta è che il primo obiettivo non è la quantità, è la continuità. Tre sedute costanti per un anno valgono infinitamente più di sei sedute per tre settimane.</p>',
|
||||||
|
'<h2>Le tre cose che non dovrebbero mancare</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li><strong>Camminare tutti i giorni.</strong> È la base gratuita su cui si appoggia tutto il resto. Va benissimo distribuita: due tratte a piedi al posto dell\'auto valgono quanto un\'unica passeggiata.</li>',
|
||||||
|
'<li><strong>Lavorare contro resistenza due volte a settimana.</strong> Pesi, elastici, corpo libero. Serve a mantenere il muscolo, che dopo i venticinque anni si perde spontaneamente se non lo si usa.</li>',
|
||||||
|
'<li><strong>Alzare il fiato una volta a settimana.</strong> Una salita, una corsa breve, una lezione più intensa: qualcosa che faccia respirare a fondo.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<h2>"Non voglio andare in palestra, non voglio i muscoli"</h2>',
|
||||||
|
'<p>È un\'obiezione frequente e nasce da un equivoco: la sala pesi non è una disciplina sportiva, è un luogo pieno di attrezzi che serve a sostenere tutto il resto. Il corridore ci va per non infortunarsi, chi ha mal di schiena per rinforzare ciò che lo protegge, chi ha superato i sessanta per continuare a salire le scale con la spesa in mano.</p>',
|
||||||
|
'<h2>Partire da dove si è</h2>',
|
||||||
|
'<p>Chi arriva da mesi di fermo non ha bisogno di un programma da atleta, ha bisogno di un programma che riesca a rispettare. Due sedute a settimana, brevi, con esercizi semplici, sono un punto di partenza serio. Il carico si aggiunge dopo, quando l\'abitudine regge da sola.</p>',
|
||||||
|
'<p>Un ultimo pensiero: il corpo non distingue fra allenamento e vita. Portare la spesa, fare le scale, andare in bicicletta al lavoro contano davvero, e per molte persone sono la parte più grande del movimento settimanale.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'il-peso-non-e-solo-la-bilancia',
|
||||||
|
title: 'Il peso non si decide sulla bilancia',
|
||||||
|
excerpt: 'Cinque pilastri in ordine di importanza, e perché il lavoro vero avviene mentre dormiamo.',
|
||||||
|
body: p(
|
||||||
|
'<p>Quando si parla di perdere peso l\'attenzione va quasi sempre a un dettaglio: un alimento da eliminare, un integratore, una nuova dieta. Le cose che contano davvero sono poche e stanno in una gerarchia precisa.</p>',
|
||||||
|
'<h2>La piramide, dal basso verso l\'alto</h2>',
|
||||||
|
'<ol>',
|
||||||
|
'<li><strong>Il bilancio calorico.</strong> Per perdere grasso bisogna consumare più di quanto si introduce. Comunque lo si ottenga, senza questo non succede nulla.</li>',
|
||||||
|
'<li><strong>Il sonno.</strong> Dormire poco altera gli ormoni della fame e rende molto più difficile mantenere il resto.</li>',
|
||||||
|
'<li><strong>Le proteine.</strong> Saziano, proteggono il muscolo mentre si dimagrisce e riducono la perdita di massa magra.</li>',
|
||||||
|
'<li><strong>Il lavoro con i pesi.</strong> Dà all\'organismo la ragione per tenere il muscolo invece di smontarlo.</li>',
|
||||||
|
'<li><strong>Il cardio.</strong> Utile, ma all\'ultimo posto: aiuta il bilancio, non lo sostituisce.</li>',
|
||||||
|
'</ol>',
|
||||||
|
'<h2>Il weekend conta come i giorni feriali</h2>',
|
||||||
|
'<p>È l\'inciampo più comune: cinque giorni impeccabili e due giorni in cui, senza accorgersene, si recupera tutto il deficit costruito. Non serve rinunciare alla pizza; serve sapere che la settimana si giudica per intero.</p>',
|
||||||
|
'<h2>Il dimagrimento avviene di notte</h2>',
|
||||||
|
'<p>Gli stimoli si danno di giorno — mangiando in un certo modo, allenandosi — ma il lavoro metabolico si compie durante il riposo. Una parte importante del grasso smaltito lascia l\'organismo attraverso la respirazione notturna. È un altro motivo per cui chi dorme male fatica a vedere risultati anche facendo tutto il resto.</p>',
|
||||||
|
'<h2>Il corpo deve stare tranquillo</h2>',
|
||||||
|
'<p>Stress prolungato, notti corte, giornate senza pause: in queste condizioni l\'organismo tende a trattenere. Prima di aggiungere un\'altra restrizione alimentare, spesso conviene togliere qualcosa dal carico complessivo.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'proteine-a-tavola',
|
||||||
|
title: 'Proteine a tavola: come distribuirle nella giornata',
|
||||||
|
excerpt: 'Senza bilancia e senza calcoli complicati. Quantità pratiche, fonti quotidiane e l\'errore più comune: concentrare tutto a cena.',
|
||||||
|
body: p(
|
||||||
|
'<p>Le proteine sono il mattone con cui l\'organismo ripara e mantiene i tessuti, muscolo compreso. Molte persone ne assumono meno di quanto pensino, soprattutto a colazione e a pranzo.</p>',
|
||||||
|
'<h2>Un ordine di grandezza, non un calcolo</h2>',
|
||||||
|
'<p>Per chi si allena con regolarità, un riferimento pratico è circa un grammo e mezzo per chilo di peso al giorno: attorno ai cento grammi per una persona di settanta chili. Per chi ha una vita più sedentaria il fabbisogno è più basso, ma resta più alto di quanto la maggior parte delle persone arrivi a coprire.</p>',
|
||||||
|
'<h2>Che cosa significa in pratica</h2>',
|
||||||
|
'<p>Senza pesare nulla, aiuta ricordare gli ordini di grandezza: una porzione di carne o pesce da mano aperta fornisce circa 25-30 grammi di proteine, due uova circa 12, un vasetto di yogurt greco 15-20, una porzione di legumi 12-15.</p>',
|
||||||
|
'<h2>L\'errore più comune</h2>',
|
||||||
|
'<p>Colazione dolce, pranzo veloce, e tutte le proteine concentrate a cena. L\'organismo utilizza meglio stimoli ripetuti nell\'arco della giornata: distribuirle su tre o quattro pasti rende lo stesso totale più efficace, e per di più aiuta a non arrivare affamati alla sera.</p>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>A colazione: yogurt, uova, ricotta, o una fonte proteica accanto ai carboidrati abituali.</li>',
|
||||||
|
'<li>A pranzo: che ci sia sempre una fonte riconoscibile, non solo un piatto di pasta.</li>',
|
||||||
|
'<li>A cena: la porzione consueta, senza doverla raddoppiare per compensare.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<h2>E le polveri?</h2>',
|
||||||
|
'<p>Sono un alimento comodo, non un prodotto magico: servono quando raggiungere la quota con il cibo è difficile per abitudini o orari. Chi già ci arriva mangiando non ne ha bisogno.</p>',
|
||||||
|
'<p>Un\'ultima cosa: quello che conta è la media delle settimane, non la giornata perfetta. Un giorno sotto la quota non rovina nulla.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'doccia-fredda-come-iniziare',
|
||||||
|
title: 'Doccia fredda: come iniziare senza esagerare',
|
||||||
|
excerpt: 'Una progressione in quattro settimane per abituarsi al freddo, con le avvertenze da tenere presenti prima di cominciare.',
|
||||||
|
body: p(
|
||||||
|
'<p>L\'esposizione al freddo è diventata popolare, spesso nella sua versione più estrema. La buona notizia è che per ottenere qualcosa non serve un bagno di ghiaccio: basta la doccia di casa, con una progressione ragionevole.</p>',
|
||||||
|
'<h2>Perché fa bene</h2>',
|
||||||
|
'<p>Il freddo allena i vasi sanguigni attraverso cicli di restringimento e dilatazione, migliorando la circolazione periferica. Aumenta inoltre i neurotrasmettitori legati ad attenzione e buonumore: è la ragione della sensazione di lucidità che resta per ore.</p>',
|
||||||
|
'<h2>Una progressione realistica</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li><strong>Settimana 1.</strong> Doccia normale, poi trenta secondi di acqua fredda alla fine, partendo dai piedi e salendo.</li>',
|
||||||
|
'<li><strong>Settimana 2.</strong> Un minuto, respirando lentamente dal naso invece di trattenere il fiato.</li>',
|
||||||
|
'<li><strong>Settimana 3.</strong> Due minuti, includendo la nuca e le spalle.</li>',
|
||||||
|
'<li><strong>Settimana 4.</strong> Due o tre minuti, quattro o cinque volte a settimana.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Il momento di disagio fa parte del meccanismo: se l\'acqua è appena tiepida non succede granché. Il respiro è lo strumento per gestirlo — quando accorcia e si fa affannoso, si rallenta l\'espirazione.</p>',
|
||||||
|
'<h2>Le avvertenze</h2>',
|
||||||
|
'<p>Chi ha problemi di cuore, di pressione o è in gravidanza dovrebbe parlarne prima con il proprio medico. Mai in acque libere da soli, e attenzione al riscaldamento dopo: la temperatura interna continua a scendere per qualche minuto anche una volta usciti, quindi si torna al caldo con gradualità, senza passare direttamente a un bagno bollente.</p>',
|
||||||
|
'<h2>Un dettaglio per chi si allena</h2>',
|
||||||
|
'<p>Subito dopo una seduta di pesi il freddo attenua parte della risposta che serve al muscolo per adattarsi. Se l\'obiettivo è la massa, meglio spostarlo al mattino o comunque a distanza di qualche ora dall\'allenamento.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'cinque-minuti-di-respiro',
|
||||||
|
title: 'Cinque minuti di respiro al giorno',
|
||||||
|
excerpt: 'Un esercizio semplice da fare ovunque per abbassare la tensione, e come usarlo prima di dormire o prima di una situazione difficile.',
|
||||||
|
body: p(
|
||||||
|
'<p>Il respiro è l\'unica funzione automatica che possiamo guidare a piacere. Questo lo rende lo strumento più immediato che abbiamo per intervenire sul nostro stato: non richiede attrezzi, nessuno se ne accorge e funziona in pochi minuti.</p>',
|
||||||
|
'<h2>La regola di base</h2>',
|
||||||
|
'<p>Espirare più a lungo di quanto si inspira porta il corpo verso il rilassamento. Accelerare il respiro fa il contrario, attiva. Tutto qui: il resto sono variazioni su questo principio.</p>',
|
||||||
|
'<h2>L\'esercizio da cui partire</h2>',
|
||||||
|
'<p>Seduti comodi, schiena appoggiata, respirando dal naso:</p>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>Inspira contando fino a quattro.</li>',
|
||||||
|
'<li>Espira lentamente contando fino a sei.</li>',
|
||||||
|
'<li>Continua per cinque minuti, senza forzare.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Se contare distrae, basta rendere l\'espirazione visibilmente più lunga dell\'inspirazione. La mano appoggiata sulla pancia aiuta a capire se il respiro è basso, diaframmatico, oppure resta alto nel petto.</p>',
|
||||||
|
'<h2>Quando usarlo</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li><strong>Prima di dormire</strong>, a luci basse: prepara il passaggio al riposo.</li>',
|
||||||
|
'<li><strong>Prima di una situazione che mette pressione</strong>: una riunione, un esame, una telefonata rimandata.</li>',
|
||||||
|
'<li><strong>Dopo l\'allenamento</strong>, per accompagnare il ritorno alla calma.</li>',
|
||||||
|
'<li><strong>Nei risvegli notturni</strong>, invece di guardare l\'orologio.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<h2>Due cose da sapere</h2>',
|
||||||
|
'<p>La prima: respirare di più non significa ossigenarsi meglio. Le respirazioni profonde e affannate a vuoto spesso peggiorano la sensazione di fame d\'aria. La seconda: di giorno, a riposo, conviene respirare dal naso — filtra, riscalda e umidifica l\'aria, e la respirazione risulta più efficiente.</p>',
|
||||||
|
'<p>Cinque minuti al giorno per due settimane sono sufficienti per accorgersi della differenza, soprattutto sulla qualità del riposo.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'luce-del-mattino',
|
||||||
|
title: 'La luce del mattino è la sveglia naturale',
|
||||||
|
excerpt: 'Dieci minuti all\'aperto appena alzati cambiano il sonno della notte successiva. E la sera, qualche accorgimento con gli schermi.',
|
||||||
|
body: p(
|
||||||
|
'<p>C\'è un gesto che costa zero e migliora il sonno più di molti integratori: uscire all\'aperto poco dopo essersi alzati.</p>',
|
||||||
|
'<h2>Come funziona</h2>',
|
||||||
|
'<p>La luce del mattino contiene un segnale che raggiunge l\'orologio interno e gli comunica che la giornata è cominciata. Da quel momento parte una catena di attivazioni — energia, digestione, attenzione — e viene impostato il conto alla rovescia per la melatonina, l\'ormone che la sera concilierà il sonno. È per questo che la notte si prepara al mattino.</p>',
|
||||||
|
'<h2>Quanto e come</h2>',
|
||||||
|
'<p>Dieci o quindici minuti nella prima ora dal risveglio sono sufficienti. Non serve sole pieno: anche con il cielo coperto la luce esterna è molto più intensa di qualsiasi lampada di casa. Vale il caffè sul balcone, la prima parte del tragitto a piedi, il cane portato fuori senza fretta. Attraverso il vetro della finestra l\'effetto è molto ridotto.</p>',
|
||||||
|
'<h2>E la sera</h2>',
|
||||||
|
'<p>Le luci bianche e gli schermi raccontano al cervello che è ancora giorno. Non serve rinunciare alla televisione: bastano un paio di accorgimenti.</p>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>Spegnere le luci centrali dopo cena e usare lampade basse e calde.</li>',
|
||||||
|
'<li>Attivare la modalità notturna su telefono e computer.</li>',
|
||||||
|
'<li>Tenere la camera completamente buia, comprese le spie degli apparecchi.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<h2>Per chi lavora su turni</h2>',
|
||||||
|
'<p>Chi ha orari che cambiano non può seguire il sole, ma può essere coerente: luce forte all\'inizio del proprio turno, penombra nelle ore precedenti al sonno, camera buia e fresca anche di giorno. La regolarità possibile vale più della regolarità perfetta.</p>',
|
||||||
|
'<p>Una nota che sorprende molti: addormentarsi in pochi secondi non è un buon segno. Di solito indica un debito di sonno accumulato.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'quando-fermarsi',
|
||||||
|
title: 'Come capire quando serve fermarsi',
|
||||||
|
excerpt: 'I segnali che distinguono la stanchezza normale da quella che chiede una pausa, e cosa fare nei giorni di scarico.',
|
||||||
|
body: p(
|
||||||
|
'<p>Chi si allena con costanza prima o poi incontra la stessa domanda: oggi vado o mi fermo? Rispondere solo con la forza di volontà porta lontano finché regge, poi presenta il conto.</p>',
|
||||||
|
'<h2>I segnali che vale la pena osservare</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li>Sonno peggiorato pur andando a letto alla stessa ora.</li>',
|
||||||
|
'<li>Frequenza cardiaca a riposo più alta del solito per più giorni.</li>',
|
||||||
|
'<li>Irritabilità, motivazione in calo, voglia di saltare l\'allenamento che dura da una settimana.</li>',
|
||||||
|
'<li>Prestazioni in discesa nonostante l\'impegno non sia cambiato.</li>',
|
||||||
|
'<li>Piccoli fastidi articolari che compaiono e non se ne vanno.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Un solo segnale isolato dice poco. Tre insieme, per più giorni, sono un\'indicazione chiara.</p>',
|
||||||
|
'<h2>Fermarsi non vuol dire stare fermi</h2>',
|
||||||
|
'<p>La pausa migliore raramente è il divano. Camminare, pedalare piano, fare mobilità o stretching aiuta la circolazione della linfa e accelera la rigenerazione più dell\'immobilità. È quello che si chiama recupero attivo: si toglie l\'intensità, non il movimento.</p>',
|
||||||
|
'<h2>Programmare lo scarico</h2>',
|
||||||
|
'<p>Nei percorsi ben costruiti la settimana leggera non è un imprevisto, è prevista: ogni tre o quattro settimane di carico se ne inserisce una più tranquilla. Chi la salta sistematicamente non ottiene più risultati, ne ottiene meno.</p>',
|
||||||
|
'<h2>Il carico è uno solo</h2>',
|
||||||
|
'<p>L\'organismo non tiene contabilità separate fra palestra, lavoro e vita privata. Una settimana pesante in ufficio, con poche ore di sonno, è già un carico: aggiungere sedute intense in quel periodo raramente è una buona idea. Riconoscerlo non è mancanza di disciplina, è saper leggere il quadro intero.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'il-piatto-della-longevita',
|
||||||
|
title: 'Il piatto della longevità',
|
||||||
|
excerpt: 'Un modello visivo al posto delle tabelle: metà verdure, un quarto carboidrati, un quarto proteine. Semplice da ricordare, difficile da sbagliare.',
|
||||||
|
body: p(
|
||||||
|
'<p>Le vecchie piramidi alimentari, con le loro frequenze settimanali, erano difficili da tradurre nella spesa del martedì. Un modello più semplice guarda direttamente al piatto che abbiamo davanti.</p>',
|
||||||
|
'<h2>Come si compone</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li><strong>Metà piatto di verdura</strong>, meglio se di colori diversi nell\'arco della settimana.</li>',
|
||||||
|
'<li><strong>Un quarto di carboidrati</strong>, preferendo quelli integrali e poco lavorati.</li>',
|
||||||
|
'<li><strong>Un quarto di proteine</strong>: pesce, carne, uova, legumi, latticini.</li>',
|
||||||
|
'<li><strong>Una fonte di grassi buoni</strong>: olio extravergine, frutta secca, avocado.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<p>Non è una dieta e non prevede divieti. È uno schema che, applicato alla maggior parte dei pasti, tiene in ordine porzioni e varietà senza contare nulla.</p>',
|
||||||
|
'<h2>Due idee da smontare</h2>',
|
||||||
|
'<p><strong>I grassi non sono il nemico.</strong> Servono ad assorbire alcune vitamine, a costruire le membrane delle cellule e a produrre gli ormoni. Eliminarli fa danni. Cambia molto, invece, quali si scelgono.</p>',
|
||||||
|
'<p><strong>I carboidrati non fanno ingrassare di per sé.</strong> A parità di calorie complessive il peso dipende dal bilancio fra ciò che si introduce e ciò che si consuma. Quello che cambia è la qualità: un cereale integrale e una brioche si comportano in modo molto diverso nelle ore successive.</p>',
|
||||||
|
'<h2>La direzione generale</h2>',
|
||||||
|
'<p>Se dovessimo riassumere in una frase l\'alimentazione orientata alla longevità: nutrire molto, infiammare poco. Vuol dire più cibi veri e meno ultra-processati, più fibre, pesce con regolarità, e attenzione a zuccheri aggiunti e alcol — che restano la parte più facile da ridurre e quella che dà i risultati più visibili.</p>',
|
||||||
|
'<p>Nessuno mangia così ogni giorno, ed è normale. La media di un mese conta più del singolo pasto.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
autore: DONATA, categoria: EDUCATIVI,
|
||||||
|
slug: 'cervello-in-allenamento',
|
||||||
|
title: 'Tenere il cervello in allenamento',
|
||||||
|
excerpt: 'Movimento, sonno, relazioni e cose nuove da imparare: le abitudini che proteggono la mente invecchiano meglio di qualsiasi integratore.',
|
||||||
|
body: p(
|
||||||
|
'<p>Parliamo molto di come mantenere il corpo e poco di come mantenere la testa, eppure il cervello risponde agli stimoli con la stessa logica del muscolo: quello che non viene usato, l\'organismo lo mette da parte.</p>',
|
||||||
|
'<h2>Età sulla carta d\'identità ed età reale</h2>',
|
||||||
|
'<p>Esiste una differenza fra l\'età anagrafica e quella biologica, cioè l\'età alla quale l\'organismo sta effettivamente funzionando. Le abitudini quotidiane spostano la seconda in entrambe le direzioni, e il cervello ha una sua velocità di invecchiamento, distinta da quella del resto del corpo.</p>',
|
||||||
|
'<h2>Che cosa lo protegge davvero</h2>',
|
||||||
|
'<ul>',
|
||||||
|
'<li><strong>Il movimento.</strong> È l\'intervento con le prove più solide: migliora l\'irrorazione cerebrale e favorisce i processi di mantenimento dei neuroni.</li>',
|
||||||
|
'<li><strong>Il sonno.</strong> Durante la notte il cervello riordina i ricordi e smaltisce le scorie prodotte di giorno. Le notti corte croniche si pagano su memoria e attenzione.</li>',
|
||||||
|
'<li><strong>Le relazioni.</strong> L\'isolamento è un fattore di rischio per il declino cognitivo, con un peso paragonabile a quello di altri fattori più discussi.</li>',
|
||||||
|
'<li><strong>Imparare cose nuove.</strong> Non i giochini di allenamento mentale, che migliorano soprattutto in quei giochini: una lingua, uno strumento, un ballo, un mestiere. Meglio se difficili.</li>',
|
||||||
|
'<li><strong>La tavola.</strong> Pesce, verdura, frutta secca, olio extravergine; poco zucchero e poco alcol.</li>',
|
||||||
|
'</ul>',
|
||||||
|
'<h2>Un\'osservazione che vale per tutto</h2>',
|
||||||
|
'<p>Queste abitudini sono le stesse che proteggono cuore, metabolismo e articolazioni. Non esistono cinque programmi diversi da seguire: esiste un modo di vivere la giornata che lavora contemporaneamente su tutti i fronti.</p>',
|
||||||
|
'<p>E vale a ogni età. Cominciare a sessant\'anni dà meno margine che cominciare a trenta, ma il margine c\'è, ed è documentato.</p>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
function idAutore(nome) {
|
||||||
|
const esistente = db.prepare('SELECT id FROM users WHERE username = ?').get(nome);
|
||||||
|
if (esistente) return esistente.id;
|
||||||
|
// Password casuale: l'account nasce come firma, non per essere usato subito.
|
||||||
|
const hash = bcrypt.hashSync(randomBytes(12).toString('base64url'), 12);
|
||||||
|
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||||
|
.run(nome, hash, 'user');
|
||||||
|
console.log(`Creato l'autore ${nome} (imposta una password dal pannello Utenti per usarlo).`);
|
||||||
|
return Number(res.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autori = new Map();
|
||||||
|
let inseriti = 0;
|
||||||
|
let saltati = 0;
|
||||||
|
|
||||||
|
const inserisci = db.prepare(
|
||||||
|
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
|
||||||
|
VALUES (@title, @slug, @category, @excerpt, @body_html, NULL, 0, @author_id, datetime('now', @quando))`
|
||||||
|
);
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
articoli.forEach((a, i) => {
|
||||||
|
if (db.prepare('SELECT 1 FROM posts WHERE slug = ?').get(a.slug)) {
|
||||||
|
saltati += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!autori.has(a.autore)) autori.set(a.autore, idAutore(a.autore));
|
||||||
|
inserisci.run({
|
||||||
|
title: a.title,
|
||||||
|
slug: a.slug,
|
||||||
|
category: a.categoria,
|
||||||
|
excerpt: a.excerpt,
|
||||||
|
body_html: a.body,
|
||||||
|
author_id: autori.get(a.autore),
|
||||||
|
// Pubblicazioni scaglionate a ritroso, così l'elenco non ha venti articoli alla stessa ora.
|
||||||
|
quando: `-${i * 2} days`,
|
||||||
|
});
|
||||||
|
inseriti += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tx();
|
||||||
|
console.log(`Blog: ${inseriti} articoli inseriti, ${saltati} già presenti.`);
|
||||||
|
db.close();
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Porta nel database i testi decisi dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
|
||||||
|
*
|
||||||
|
* Perché serve uno script e non basta il deploy: i testi mostrati dal sito vivono in
|
||||||
|
* `content_blocks`, e `resolveContent` legge **prima** il database e solo dopo il seed nel
|
||||||
|
* codice. Un tag già presente in tabella continua quindi a mostrare il vecchio valore anche
|
||||||
|
* dopo che il seed è stato aggiornato — in produzione sono 19 righe.
|
||||||
|
*
|
||||||
|
* Perché una tantum e non una migrazione all'avvio: una riscrittura a ogni restart
|
||||||
|
* rimetterebbe le mani su testi che il cliente può cambiare dal pannello. Qui si passa una
|
||||||
|
* volta, si guarda cosa è cambiato, e poi quei testi tornano suoi.
|
||||||
|
*
|
||||||
|
* Uso: node scripts/testi-17-agosto.mjs [percorso.db] [--dry-run]
|
||||||
|
*/
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Small Group" diventa "Personalized Training Group", **con gli articoli giusti**.
|
||||||
|
*
|
||||||
|
* Una sostituzione meccanica produrrebbe "Lo Personalized Training Group" e "gli
|
||||||
|
* personalized training group": le due frasi che portano un articolo si correggono per
|
||||||
|
* intero, prima. Il resto è la sostituzione del nome, che resta capitalizzato come nome
|
||||||
|
* proprio anche dove prima era minuscolo ("in formula small group" → "in formula
|
||||||
|
* Personalized Training Group").
|
||||||
|
*
|
||||||
|
* Idempotente: al secondo giro non c'è più niente da sostituire.
|
||||||
|
*/
|
||||||
|
export function rinominaSmallGroup(value) {
|
||||||
|
return value
|
||||||
|
.replace(/\bLo Small Group\b/g, 'Il Personalized Training Group')
|
||||||
|
.replace(/\bgli small groups?\b/gi, 'i Personalized Training Group')
|
||||||
|
.replace(/\bsmall groups?\b/gi, 'Personalized Training Group');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I testi riscritti per intero, tag per tag: il cliente ne ha dettato il contenuto nuovo,
|
||||||
|
* quindi non c'è una parola da sostituire ma una frase da rimpiazzare.
|
||||||
|
*
|
||||||
|
* Sono solo i tag che in produzione esistono già con il valore vecchio. Quelli nuovi (la
|
||||||
|
* sezione «Check-up Avanzato», gli eventi, la pagina di scelta) non stanno in tabella e li
|
||||||
|
* serve il seed dal codice, senza bisogno di scriverli qui.
|
||||||
|
*/
|
||||||
|
export const TESTI = {
|
||||||
|
'service.profilazione.title': 'Check-up',
|
||||||
|
'service.profilazione.subtitle': 'Base e avanzato',
|
||||||
|
'service.profilazione.excerpt':
|
||||||
|
'Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — un’analisi completa e scientifica che guida le nostre scelte.',
|
||||||
|
'footer.services.4.label': 'Check-up',
|
||||||
|
'services.meta.description':
|
||||||
|
'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** I cambiamenti da fare, con il prima e il dopo. Non tocca niente. */
|
||||||
|
export function calcola(db) {
|
||||||
|
const righe = db.prepare('SELECT tag, value FROM content_blocks').all();
|
||||||
|
return righe
|
||||||
|
.map((r) => ({
|
||||||
|
tag: r.tag,
|
||||||
|
prima: r.value,
|
||||||
|
dopo: r.tag in TESTI ? TESTI[r.tag] : rinominaSmallGroup(r.value),
|
||||||
|
}))
|
||||||
|
.filter((c) => c.prima !== c.dopo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applica(db, { dryRun = false } = {}) {
|
||||||
|
const cambi = calcola(db);
|
||||||
|
if (!dryRun && cambi.length) {
|
||||||
|
const upd = db.prepare(
|
||||||
|
"UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = 'testi-17-agosto' WHERE tag = ?",
|
||||||
|
);
|
||||||
|
db.transaction(() => { for (const c of cambi) upd.run(c.dopo, c.tag); })();
|
||||||
|
}
|
||||||
|
return cambi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const path = args.find((a) => !a.startsWith('--')) ?? process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||||
|
const db = new Database(path);
|
||||||
|
const cambi = applica(db, { dryRun });
|
||||||
|
for (const c of cambi) console.log(`${c.tag}\n - ${c.prima}\n + ${c.dopo}\n`);
|
||||||
|
console.log(`${cambi.length} contenuti ${dryRun ? 'da aggiornare (dry-run, niente scritto)' : 'aggiornati'} in ${path}`);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 480 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 554 KiB |
@@ -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>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { site } from '../data/site';
|
import { site } from '../data/site';
|
||||||
import { t, contentImageUrl } from '../lib/content';
|
import { t, contentImageUrl } from '../lib/content';
|
||||||
|
import { landingFor } from '../lib/auth';
|
||||||
|
import { linkConsulenza } from '../data/consulenza';
|
||||||
interface Props {
|
interface Props {
|
||||||
user: { username: string; role: string } | null;
|
user: { username: string; role: string } | null;
|
||||||
showTags: boolean;
|
showTags: boolean;
|
||||||
@@ -8,8 +10,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const { user, canTags, showTags } = Astro.props;
|
const { user, canTags, showTags } = Astro.props;
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const loginHref = `/login?next=${encodeURIComponent(path)}`;
|
// ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo
|
||||||
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
// sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che
|
||||||
|
// esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta.
|
||||||
|
// ⚠️ L'indirizzo vero, oggi: apps.insanitylab.it NON esiste (manca il record su Aruba) e
|
||||||
|
// puntarci scriverebbe nel menu del sito un link morto. Quando quel record ci sara', qui
|
||||||
|
// si cambia una riga — ed e' l'unico posto del sito che nomina l'area riservata.
|
||||||
|
const AREA_RISERVATA = 'https://piattaforme.tielogic.xyz';
|
||||||
|
const areaHref = user ? landingFor(user.role) : '/admin';
|
||||||
|
// Consulenza con Nicola su ogni pagina, non solo sulla Reformer Class (Adriano 30/09, #22).
|
||||||
|
const consulenzaHref = linkConsulenza(t('global.consulenza.url'));
|
||||||
---
|
---
|
||||||
<header class="hdr" id="site-header">
|
<header class="hdr" id="site-header">
|
||||||
<div class="container hdr__in">
|
<div class="container hdr__in">
|
||||||
@@ -20,7 +30,9 @@ const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
|||||||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
|
<a class="hdr__link hdr__consulenza-mob" href={consulenzaHref} target="_blank" rel="noopener noreferrer">{t('global.consulenza.cta-breve')}</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<a class="hdr__consulenza" href={consulenzaHref} target="_blank" rel="noopener noreferrer">{t('global.consulenza.cta-breve')}</a>
|
||||||
<div class="hdr__user">
|
<div class="hdr__user">
|
||||||
{user ? (
|
{user ? (
|
||||||
<details class="umenu">
|
<details class="umenu">
|
||||||
@@ -34,7 +46,7 @@ const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
) : (
|
) : (
|
||||||
<a class="hdr__login" href={loginHref}>Accedi</a>
|
<a class="hdr__login" href={AREA_RISERVATA}>Area riservata</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
|
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
|
||||||
@@ -48,8 +60,22 @@ const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
|||||||
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
|
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
|
||||||
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
|
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
|
||||||
.hdr__nav { display: flex; gap: 34px; }
|
.hdr__nav { display: flex; gap: 34px; }
|
||||||
|
/* Col bottone Consulenza nella barra le voci non devono andare a capo (#22). */
|
||||||
|
@media (min-width: 992px) { .hdr__nav { gap: 22px; } .hdr__link, .hdr__login { white-space: nowrap; } .hdr__logo { flex-shrink: 0; margin-right: 28px; } }
|
||||||
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
|
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
|
||||||
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
|
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
|
||||||
|
.hdr__link--bio { color: var(--c-accent-dark); }
|
||||||
|
/* Menu Piattaforme: su desktop è un pannello che scende dalla voce, in mobile —
|
||||||
|
dove la nav è già una colonna — le voci si aprono in linea sotto il titolo. */
|
||||||
|
.plat { position: relative; }
|
||||||
|
.plat__btn { cursor: pointer; list-style: none; }
|
||||||
|
.plat__btn::-webkit-details-marker { display: none; }
|
||||||
|
.plat__panel {
|
||||||
|
position: absolute; left: 0; top: calc(100% + 14px); background: #fff; min-width: 210px;
|
||||||
|
box-shadow: 0 8px 22px rgba(0,0,0,.12); display: flex; flex-direction: column; padding: 8px 0; z-index: 200;
|
||||||
|
}
|
||||||
|
.plat__item { padding: 10px 16px; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
|
||||||
|
.plat__item:hover, .plat__item.is-active { background: #f4f2ef; color: var(--c-accent-dark); }
|
||||||
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
|
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
|
||||||
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
|
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
@@ -57,6 +83,15 @@ const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
|||||||
.hdr__nav.is-open { display: flex; }
|
.hdr__nav.is-open { display: flex; }
|
||||||
.hdr__link { padding: 12px 0; }
|
.hdr__link { padding: 12px 0; }
|
||||||
.hdr__burger { display: block; }
|
.hdr__burger { display: block; }
|
||||||
|
.plat__panel { position: static; box-shadow: none; padding: 0 0 8px; min-width: 0; }
|
||||||
|
.plat__item { padding: 10px 0 10px 14px; }
|
||||||
|
}
|
||||||
|
.hdr__consulenza { font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; text-decoration: none; color: #fff; background: #6b5d5b; padding: 10px 14px; margin: 0 18px; white-space: nowrap; transition: background .2s; }
|
||||||
|
.hdr__consulenza:hover { background: var(--c-accent-dark); }
|
||||||
|
.hdr__consulenza-mob { display: none; }
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.hdr__consulenza { display: none; }
|
||||||
|
.hdr__consulenza-mob { display: block; color: var(--c-accent-dark); }
|
||||||
}
|
}
|
||||||
.hdr__user { display: flex; align-items: center; }
|
.hdr__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 { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||||||
@@ -79,6 +114,15 @@ const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
|||||||
toggle.setAttribute('aria-expanded', String(open));
|
toggle.setAttribute('aria-expanded', String(open));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Il pannello Piattaforme è un <details>: da solo resterebbe aperto anche cliccando altrove.
|
||||||
|
const plat = document.querySelector<HTMLDetailsElement>('.plat');
|
||||||
|
if (plat) {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (plat.open && !plat.contains(e.target as Node)) plat.open = false;
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') plat.open = false; });
|
||||||
|
}
|
||||||
|
|
||||||
const tagsBtn = document.getElementById('toggle-tags');
|
const tagsBtn = document.getElementById('toggle-tags');
|
||||||
if (tagsBtn) {
|
if (tagsBtn) {
|
||||||
const on = tagsBtn.dataset.on === '1';
|
const on = tagsBtn.dataset.on === '1';
|
||||||
|
|||||||
@@ -42,5 +42,14 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
|
|||||||
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
|
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
|
||||||
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
|
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
|
||||||
.method__step p { font-size: .85rem; color: var(--c-text-light); }
|
.method__step p { font-size: .85rem; color: var(--c-text-light); }
|
||||||
@media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } }
|
@media (max-width: 767px) {
|
||||||
|
/* una colonna con lo step in orizzontale: a due colonne i titoli lunghi
|
||||||
|
si spezzavano su quattro righe. */
|
||||||
|
.method__row { grid-template-columns: 1fr; gap: 28px; text-align: left; }
|
||||||
|
.method__step { display: grid; grid-template-columns: 104px 1fr; column-gap: 18px; align-items: start; }
|
||||||
|
.method__circle { width: 104px; height: 104px; margin: 0; grid-row: 1 / span 2; }
|
||||||
|
.method__circle span { font-size: 1.8rem; top: -4px; left: -4px; }
|
||||||
|
.method__step h3 { margin-top: 4px; }
|
||||||
|
.method__note { text-align: left; margin-left: 0; max-width: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
|
|||||||
.pstrip { background: var(--c-accent); padding-block: 40px; }
|
.pstrip { background: var(--c-accent); padding-block: 40px; }
|
||||||
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
|
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
|
||||||
.pstrip img { height: 44px; width: auto; opacity: .9; }
|
.pstrip img { height: 44px; width: auto; opacity: .9; }
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.pstrip { padding-block: 28px; }
|
||||||
|
.pstrip__in { justify-content: center; gap: 20px 28px; }
|
||||||
|
.pstrip img { height: 30px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { trainings } from '../../data/trainings';
|
|||||||
import T from '../content/T.astro';
|
import T from '../content/T.astro';
|
||||||
import TImg from '../content/TImg.astro';
|
import TImg from '../content/TImg.astro';
|
||||||
import { t } from '../../lib/content';
|
import { t } from '../../lib/content';
|
||||||
|
// `items` permette di mostrare solo una parte dei training (le viste di /training):
|
||||||
|
// senza, la fascia di card resterebbe l'elenco intero sopra ai blocchi filtrati.
|
||||||
|
interface Props { items?: readonly (typeof trainings)[number][] }
|
||||||
|
const { items = trainings } = Astro.props;
|
||||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
||||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||||
const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
||||||
@@ -11,7 +15,7 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
|||||||
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
||||||
---
|
---
|
||||||
<section class="tcards">
|
<section class="tcards">
|
||||||
{trainings.map((tr, i) => {
|
{items.map((tr, i) => {
|
||||||
const img = imgOf(tr.image);
|
const img = imgOf(tr.image);
|
||||||
const card = (
|
const card = (
|
||||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
||||||
@@ -46,5 +50,11 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
|||||||
.tcards__photo { overflow: hidden; }
|
.tcards__photo { overflow: hidden; }
|
||||||
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
|
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
|
||||||
.tcards__photo:hover .tcards__img { transform: scale(1.06); }
|
.tcards__photo:hover .tcards__img { transform: scale(1.06); }
|
||||||
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } }
|
@media (max-width: 767px) {
|
||||||
|
.tcards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.tcards__cell { padding: 22px 16px; }
|
||||||
|
.tcards__cell h3 { font-size: 1rem; letter-spacing: .08em; }
|
||||||
|
.tcards__cell p { font-size: .74rem; }
|
||||||
|
.tcards__link { font-size: .62rem; letter-spacing: .14em; margin-top: 12px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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,15 @@
|
|||||||
|
// Consulenza conoscitiva gratuita con Nicola (prenotazione su Google Calendar).
|
||||||
|
//
|
||||||
|
// Il link vive nel pannello contenuti (tag `global.consulenza.url`): qui c'è il valore di
|
||||||
|
// partenza e la guardia. Il tag è modificabile da admin, quindi si accetta solo https.
|
||||||
|
export const CONSULENZA_URL_DEFAULT = 'https://calendar.app.google/nWXMGD2S5g2pVeeD7';
|
||||||
|
|
||||||
|
/** Slug delle pagine servizio che mostrano il bottone di prenotazione. */
|
||||||
|
export const PAGINE_CON_CONSULENZA = ['reformer-class'];
|
||||||
|
|
||||||
|
/** Link da usare: il valore del pannello se è https, altrimenti quello di default
|
||||||
|
* (niente javascript:, http:, vuoto). */
|
||||||
|
export function linkConsulenza(valore: string): string {
|
||||||
|
const v = (valore ?? '').trim();
|
||||||
|
return v.startsWith('https://') ? v : CONSULENZA_URL_DEFAULT;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { partners } from './partners';
|
|||||||
import { disciplines, days, slots } from './agenda';
|
import { disciplines, days, slots } from './agenda';
|
||||||
import { services } from './services';
|
import { services } from './services';
|
||||||
import { performanceClassPricing } from './pricing';
|
import { performanceClassPricing } from './pricing';
|
||||||
|
import { CONSULENZA_URL_DEFAULT } from './consulenza';
|
||||||
|
|
||||||
export type ContentType = 'text' | 'html' | 'image';
|
export type ContentType = 'text' | 'html' | 'image';
|
||||||
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
|
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
|
||||||
@@ -33,7 +34,6 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('global.nav.4.label', site.navigation[3].label, []),
|
T('global.nav.4.label', site.navigation[3].label, []),
|
||||||
T('global.nav.5.label', site.navigation[4].label, []),
|
T('global.nav.5.label', site.navigation[4].label, []),
|
||||||
T('global.nav.6.label', site.navigation[5].label, []),
|
T('global.nav.6.label', site.navigation[5].label, []),
|
||||||
T('global.nav.7.label', site.navigation[6].label, []),
|
|
||||||
|
|
||||||
// --- Header ---
|
// --- Header ---
|
||||||
T('header.logo.alt', 'InsanityLab', []),
|
T('header.logo.alt', 'InsanityLab', []),
|
||||||
@@ -166,7 +166,7 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
// --- About: meta, hero ---
|
// --- About: meta, hero ---
|
||||||
T('about.meta.title', 'Insanitylab', []),
|
T('about.meta.title', 'Insanitylab', []),
|
||||||
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
|
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
|
||||||
T('about.hero.title', 'Insanitylab', []),
|
T('about.hero.title', 'About us', []),
|
||||||
|
|
||||||
// --- About: mission ---
|
// --- About: mission ---
|
||||||
T('about.mission.title', 'Mission'),
|
T('about.mission.title', 'Mission'),
|
||||||
@@ -223,7 +223,7 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('about.storylab.title', 'Storylab'),
|
T('about.storylab.title', 'Storylab'),
|
||||||
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
|
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
|
||||||
T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
|
T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
|
||||||
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."),
|
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come i Personalized Training Group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."),
|
||||||
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
|
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
|
||||||
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||||
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
|
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
|
||||||
@@ -232,7 +232,7 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||||
// --- Services: lista (/services) ---
|
// --- Services: lista (/services) ---
|
||||||
T('services.meta.title', 'Servizi', []),
|
T('services.meta.title', 'Servizi', []),
|
||||||
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []),
|
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.', []),
|
||||||
T('services.hero.title', 'Servizi', []),
|
T('services.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.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
|
||||||
T('services.card.more-label', 'Per saperne di più →', []),
|
T('services.card.more-label', 'Per saperne di più →', []),
|
||||||
@@ -248,6 +248,13 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
|
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
|
||||||
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
|
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
|
||||||
]),
|
]),
|
||||||
|
...(p.extra
|
||||||
|
? [
|
||||||
|
T(`service.${p.slug}.extra.title`, p.extra.title),
|
||||||
|
T(`service.${p.slug}.extra.body`, p.extra.body),
|
||||||
|
...p.extra.items.map((it, i) => T(`service.${p.slug}.extra.item.${i + 1}`, it)),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
I(`service.${p.slug}.image`),
|
I(`service.${p.slug}.image`),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -263,6 +270,11 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('service.pricing.entries-singular', 'ingresso settimanale', []),
|
T('service.pricing.entries-singular', 'ingresso settimanale', []),
|
||||||
T('service.pricing.entries-plural', 'ingressi settimanali', []),
|
T('service.pricing.entries-plural', 'ingressi settimanali', []),
|
||||||
T('service.pricing.buy-cta', 'Acquista', []),
|
T('service.pricing.buy-cta', 'Acquista', []),
|
||||||
|
T('service.contact-cta', 'Contattaci', []),
|
||||||
|
T('service.consulenza-cta', 'Prenota una consulenza conoscitiva gratuita', []),
|
||||||
|
T('service.consulenza-note', '30-45 minuti con Nicola, in sede: via Leandro Alberti 76, primo piano', []),
|
||||||
|
T('global.consulenza.url', CONSULENZA_URL_DEFAULT, []),
|
||||||
|
T('global.consulenza.cta-breve', 'Consulenza gratuita', []),
|
||||||
|
|
||||||
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
|
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
|
||||||
T('pricing.heading', performanceClassPricing.heading),
|
T('pricing.heading', performanceClassPricing.heading),
|
||||||
@@ -278,8 +290,9 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
|
|
||||||
// --- Training: meta, hero, intro, trial ---
|
// --- Training: meta, hero, intro, trial ---
|
||||||
T('training.meta.title', 'Training', []),
|
T('training.meta.title', 'Training', []),
|
||||||
T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
|
T('training.meta.description', 'One to One, Personalized Training Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
|
||||||
T('training.hero.title', 'training', []),
|
T('training.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.'),
|
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 ---
|
// --- Contact ---
|
||||||
@@ -294,6 +307,29 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('contact.info.email-label', 'E-mail'),
|
T('contact.info.email-label', 'E-mail'),
|
||||||
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
|
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
|
||||||
|
|
||||||
|
// --- Promo ed eventi: scelta (/promo-eventi) ---
|
||||||
|
T('promo-eventi.meta.title', 'Promo ed eventi', []),
|
||||||
|
T('promo-eventi.meta.description', 'Le promozioni in corso e gli eventi di InsanityLab, a partire dall’inaugurazione del nuovo spazio.', []),
|
||||||
|
T('promo-eventi.hero.title', 'Promo ed eventi', []),
|
||||||
|
T('promo-eventi.intro', 'Scegli cosa vuoi vedere: le promozioni attive in questo momento oppure gli eventi in programma al centro.'),
|
||||||
|
T('promo-eventi.promo.label', 'Promo'),
|
||||||
|
T('promo-eventi.promo.desc', 'I pacchetti e le offerte del periodo, con prezzi e condizioni.'),
|
||||||
|
T('promo-eventi.promo.nessuna', 'Nessuna promozione in corso', []),
|
||||||
|
T('promo-eventi.promo.cta', 'Vedi le promo', []),
|
||||||
|
T('promo-eventi.eventi.label', 'Eventi'),
|
||||||
|
T('promo-eventi.eventi.desc', 'Le date da segnare: aperture, presentazioni e appuntamenti in sede.'),
|
||||||
|
T('promo-eventi.eventi.cta', 'Vedi gli eventi', []),
|
||||||
|
|
||||||
|
// --- Eventi (/promo-eventi/eventi) ---
|
||||||
|
T('eventi.meta.title', 'Eventi', []),
|
||||||
|
T('eventi.meta.description', 'Gli eventi InsanityLab: inaugurazione del nuovo spazio il 19 settembre 2026, con aperitivo e dj set.', []),
|
||||||
|
T('eventi.hero.title', 'Eventi', []),
|
||||||
|
T('eventi.inaugurazione.title', 'Inaugurazione'),
|
||||||
|
T('eventi.inaugurazione.date', '19 settembre 2026'),
|
||||||
|
T('eventi.inaugurazione.body', 'Apriamo le porte del nuovo spazio con un aperitivo e dj set. Sarà l’occasione per conoscere lo staff, visitare la struttura e prenotare una prova gratuita dei nostri servizi. Il programma completo della giornata sarà pubblicato a breve.'),
|
||||||
|
T('eventi.cta.label', 'Scrivici su WhatsApp', []),
|
||||||
|
T('eventi.torna.label', '← Torna a promo ed eventi', []),
|
||||||
|
|
||||||
// --- 404 ---
|
// --- 404 ---
|
||||||
T('notfound.meta.title', 'Pagina non trovata', []),
|
T('notfound.meta.title', 'Pagina non trovata', []),
|
||||||
T('notfound.eyebrow', 'Errore 404', []),
|
T('notfound.eyebrow', 'Errore 404', []),
|
||||||
@@ -302,8 +338,6 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('notfound.cta', 'Torna alla home', []),
|
T('notfound.cta', 'Torna alla home', []),
|
||||||
|
|
||||||
// --- Promo ed eventi (promo-eventi.astro) ---
|
// --- Promo ed eventi (promo-eventi.astro) ---
|
||||||
// Titolo del banner PageHero (usato dalla variante promo-eventi2)
|
|
||||||
T('promo.pagehero.title', 'Promo ed eventi'),
|
|
||||||
// Hero
|
// Hero
|
||||||
T('promo.hero.badge', 'insanity.lab · bologna savena'),
|
T('promo.hero.badge', 'insanity.lab · bologna savena'),
|
||||||
T('promo.hero.title1', 'Allenati con metodo.'),
|
T('promo.hero.title1', 'Allenati con metodo.'),
|
||||||
@@ -323,18 +357,18 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
// Card 1 — One to One
|
// Card 1 — One to One
|
||||||
T('promo.card.1.eyebrow', 'Personal training', []),
|
T('promo.card.1.eyebrow', 'Personal training', []),
|
||||||
T('promo.card.1.title', 'One to One'),
|
T('promo.card.1.title', 'One to One'),
|
||||||
T('promo.card.1.price', '163 €', []),
|
T('promo.card.1.price', '200 €', []),
|
||||||
T('promo.card.1.price-old', '300 €', []),
|
T('promo.card.1.price-old', '300 €', []),
|
||||||
T('promo.card.1.unit', '5 incontri', []),
|
T('promo.card.1.unit', '5 incontri', []),
|
||||||
T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
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.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.3', 'PT dedicato, presente a ogni sessione'),
|
||||||
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||||
// Card 2 — Small Group (in evidenza)
|
// Card 2 — Personalized Training Group (in evidenza)
|
||||||
T('promo.card.2.badge', 'Più scelto', []),
|
T('promo.card.2.badge', 'Più scelto', []),
|
||||||
T('promo.card.2.eyebrow', 'Personal training', []),
|
T('promo.card.2.eyebrow', 'Personal training', []),
|
||||||
T('promo.card.2.title', 'Small Group'),
|
T('promo.card.2.title', 'Personalized Training Group'),
|
||||||
T('promo.card.2.price', '110 €', []),
|
T('promo.card.2.price', '125 €', []),
|
||||||
T('promo.card.2.price-old', '220 €', []),
|
T('promo.card.2.price-old', '220 €', []),
|
||||||
T('promo.card.2.unit', '5 incontri', []),
|
T('promo.card.2.unit', '5 incontri', []),
|
||||||
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||||
@@ -344,7 +378,7 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
// Card 3 — Reformer
|
// Card 3 — Reformer
|
||||||
T('promo.card.3.eyebrow', 'Pilates class', []),
|
T('promo.card.3.eyebrow', 'Pilates class', []),
|
||||||
T('promo.card.3.title', 'Reformer'),
|
T('promo.card.3.title', 'Reformer'),
|
||||||
T('promo.card.3.price', '100 €', []),
|
T('promo.card.3.price', '115 €', []),
|
||||||
T('promo.card.3.price-old', '140 €', []),
|
T('promo.card.3.price-old', '140 €', []),
|
||||||
T('promo.card.3.unit', '5 incontri', []),
|
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.1', 'Core, postura e mobilità su macchina Reformer'),
|
||||||
@@ -353,22 +387,35 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
// Card 4 — Holistic class
|
// Card 4 — Holistic class
|
||||||
T('promo.card.4.eyebrow', 'Holistic class', []),
|
T('promo.card.4.eyebrow', 'Holistic class', []),
|
||||||
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
|
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
|
||||||
T('promo.card.4.price', '50 €', []),
|
T('promo.card.4.price', '75 €', []),
|
||||||
T('promo.card.4.price-old', '100 €', []),
|
T('promo.card.4.price-old', '100 €', []),
|
||||||
T('promo.card.4.unit', '5 incontri', []),
|
T('promo.card.4.unit', '5 incontri', []),
|
||||||
T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
|
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.2', 'Da solo, o integrato nei tuoi allenamenti'),
|
||||||
T('promo.card.4.feature.3', 'Adatto a tutti i livelli'),
|
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à
|
// Nota validità
|
||||||
T('promo.validity-note', 'Disponibile solo per l\'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
|
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
|
// 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.'),
|
T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
|
||||||
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'),
|
// La FAQ 0 è mostrata per prima: numerata così per non rinumerare le esistenti (i tag già
|
||||||
H('promo.faq.1.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi ci scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare le singole sessioni in base agli orari disponibili. 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.'),
|
// 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?'),
|
T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'),
|
||||||
H('promo.faq.2.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi vai nella schermata Home, clicca il servizio che hai acquistato e seleziona autonomamente la class nel giorno e nell\'orario che desideri. 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.'),
|
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?'),
|
T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
|
||||||
H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Small Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'),
|
H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Personalized Training Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'),
|
||||||
T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
|
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.'),
|
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?'),
|
T('promo.faq.5.q', 'Il pagamento è sicuro?'),
|
||||||
@@ -376,6 +423,188 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
|
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.'),
|
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
|
||||||
|
|
||||||
|
// --- Calendario delle class (popup dalle pagine servizio) ---
|
||||||
|
// La griglia è dato (`data/calendari.ts`); qui ci sono solo le etichette attorno.
|
||||||
|
T('calendario.link-label', 'Calendario', []),
|
||||||
|
T('calendario.pdf-label', 'Scarica in PDF', []),
|
||||||
|
T('calendario.chiudi-label', 'Chiudi', []),
|
||||||
|
T('calendario.futuro-label', 'In vigore dal', []),
|
||||||
|
T('calendario.chiuso-label', 'Chiuso', []),
|
||||||
|
T('calendario.nota', 'Gli orari possono subire variazioni. Le lezioni si prenotano dall\'app Bsport.'),
|
||||||
|
|
||||||
|
// --- Pagina promo, stato "nessuna promozione in corso" ---
|
||||||
|
// Fra un'edizione e la successiva la pagina resta raggiungibile (è nel menu e nei link già
|
||||||
|
// girati ai clienti): mostra questo invece dei prezzi di una promozione finita.
|
||||||
|
T('promo.chiusa.title', 'Nessuna promozione in corso'),
|
||||||
|
H('promo.chiusa.desc', 'Le offerte di apertura sono terminate.<br>Scrivici su WhatsApp: ti raccontiamo i percorsi attivi e troviamo quello giusto per te.'),
|
||||||
|
T('promo.chiusa.cta', 'Scrivici su WhatsApp', []),
|
||||||
|
|
||||||
|
// --- Opening Bundles (edizione `bundle-apertura`, dal 20 agosto al 4 settembre 2026) ---
|
||||||
|
// Testi dal volantino fornito dal cliente il 13/08. La struttura delle card (domanda,
|
||||||
|
// descrizione, riquadro date) è dichiarata in `data/promo-edizioni.ts`.
|
||||||
|
T('bundle.hero.badge', 'New Opening · Offerta esclusiva'),
|
||||||
|
T('bundle.hero.title1', 'Opening Bundles.'),
|
||||||
|
T('bundle.hero.accent', 'Non è uno sconto.'),
|
||||||
|
T('bundle.hero.title2', 'È un accesso privilegiato.'),
|
||||||
|
H('bundle.hero.desc', 'Tre percorsi costruiti su misura, disponibili solo per la nuova apertura.<br>Acquistabili fino al 4 settembre, utilizzabili dal 1° settembre al 1° dicembre.'),
|
||||||
|
T('bundle.hero.cta', 'Scopri i tre percorsi', []),
|
||||||
|
|
||||||
|
T('bundle.buy-label', 'Acquista', []),
|
||||||
|
|
||||||
|
T('bundle.card.1.eyebrow', 'Personal Training', []),
|
||||||
|
T('bundle.card.1.title', 'Intensive'),
|
||||||
|
T('bundle.card.1.question', 'Ti alleni ma non vedi risultati?'),
|
||||||
|
T('bundle.card.1.price', '450 €', []),
|
||||||
|
T('bundle.card.1.price-old', '770 €', []),
|
||||||
|
T('bundle.card.1.feature.1', 'Profilazione Avanzata'),
|
||||||
|
T('bundle.card.1.feature.2', '8 sessioni PT 1:1'),
|
||||||
|
T('bundle.card.1.feature.3', '4 Community Class'),
|
||||||
|
T('bundle.card.1.desc', 'Un check-up avanzato per conoscere davvero il tuo corpo — poi 2 sessioni di Personal Training e una Community Class a settimana. Un mese per capire di cosa sei capace.'),
|
||||||
|
T('bundle.card.1.note-label', 'Periodo di utilizzo', []),
|
||||||
|
H('bundle.card.1.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
|
||||||
|
|
||||||
|
T('bundle.card.2.eyebrow', 'Pilates & Holistic', []),
|
||||||
|
T('bundle.card.2.title', 'Restore'),
|
||||||
|
T('bundle.card.2.question', 'Hai dolori, rigidità o ti senti bloccato?'),
|
||||||
|
T('bundle.card.2.price', '170 €', []),
|
||||||
|
T('bundle.card.2.price-old', '220 €', []),
|
||||||
|
T('bundle.card.2.feature.1', '4 Pilates Reformer'),
|
||||||
|
T('bundle.card.2.feature.2', '4 Holistic Class'),
|
||||||
|
T('bundle.card.2.desc', 'Se hai dolori, rigidità, o semplicemente vuoi prenderti cura di te con dolcezza. Reformer e Holistic Class lavorano insieme per rimettere il corpo in equilibrio — mobilità, core, respiro. Senza forzare, senza improvvisare.'),
|
||||||
|
T('bundle.card.2.note-label', 'Periodo di utilizzo', []),
|
||||||
|
H('bundle.card.2.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
|
||||||
|
|
||||||
|
T('bundle.card.3.eyebrow', 'Training & Wellness', []),
|
||||||
|
T('bundle.card.3.title', 'Recharge'),
|
||||||
|
T('bundle.card.3.question', 'Sei stanco e svuotato anche dopo una notte di sonno?'),
|
||||||
|
T('bundle.card.3.price', '250 €', []),
|
||||||
|
T('bundle.card.3.price-old', '310 €', []),
|
||||||
|
T('bundle.card.3.feature.1', 'Analisi HRV'),
|
||||||
|
T('bundle.card.3.feature.2', '4 Pilates Reformer'),
|
||||||
|
T('bundle.card.3.feature.3', '3 Training Group'),
|
||||||
|
T('bundle.card.3.desc', 'Stanco, svuotato, fuori ritmo. Attraverso l\'analisi HRV misuriamo il tuo livello di stress e il tuo stato energetico reale — poi Reformer e Training Group per farti ripartire davvero.'),
|
||||||
|
T('bundle.card.3.note-label', 'Periodo di utilizzo', []),
|
||||||
|
H('bundle.card.3.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
|
||||||
|
|
||||||
|
// La deadline è la stessa fine dell'edizione in `promo-edizioni.ts`: un test lo verifica,
|
||||||
|
// perché un countdown che scade a un'ora diversa dallo scambio fa contraddire il sito.
|
||||||
|
T('bundle.countdown.label', 'I bundle scadono tra', []),
|
||||||
|
T('bundle.countdown.deadline', '2026-09-04T23:59:59+02:00', []),
|
||||||
|
T('bundle.countdown.unit.days', 'giorni', []),
|
||||||
|
T('bundle.countdown.unit.hours', 'ore', []),
|
||||||
|
T('bundle.countdown.unit.minutes', 'minuti', []),
|
||||||
|
T('bundle.countdown.unit.seconds', 'secondi', []),
|
||||||
|
|
||||||
|
T('bundle.validity-note', 'Acquistabili fino al 4 settembre 2026 · Utilizzabili dal 1° settembre al 1° dicembre 2026'),
|
||||||
|
|
||||||
|
T('bundle.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
|
||||||
|
T('bundle.faq.0.q', 'Come faccio ad acquistare un bundle?'),
|
||||||
|
H('bundle.faq.0.a', 'Clicca sul bundle che desideri e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona il bundle che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
|
||||||
|
T('bundle.faq.1.q', 'Entro quando posso acquistarlo, e entro quando devo usarlo?'),
|
||||||
|
H('bundle.faq.1.a', 'I bundle si acquistano <span class="hl">fino al 4 settembre 2026</span> e si utilizzano <span class="hl">dal 1° settembre al 1° dicembre 2026</span>. Dopo l\'acquisto scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> per fissare il primo incontro: verrai ricontattato subito tramite messaggio o chiamata.'),
|
||||||
|
T('bundle.faq.2.q', 'Posso scambiare gli incontri fra un bundle e l\'altro?'),
|
||||||
|
H('bundle.faq.2.a', 'No: ogni bundle è un percorso costruito su un obiettivo preciso, e le attività che contiene lavorano insieme. Se ti interessano servizi di bundle diversi puoi acquistarne più di uno, oppure scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> e ti aiutiamo a scegliere.'),
|
||||||
|
T('bundle.faq.3.q', 'Posso regalare un bundle a qualcun altro?'),
|
||||||
|
H('bundle.faq.3.a', 'Sì. Puoi acquistare il bundle a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
|
||||||
|
T('bundle.faq.4.q', 'Il pagamento è sicuro?'),
|
||||||
|
H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
|
||||||
|
|
||||||
|
// --- Promo settembre (edizione `settembre-2026`, 5 → 19 settembre) ---
|
||||||
|
// Testi ripresi dall'edizione di agosto, che il cliente ha già approvato: cambia solo ciò che
|
||||||
|
// le date o le nuove creative rendono falso. I prezzi e le voci degli elenchi vengono dalle
|
||||||
|
// sei creative consegnate il 03/09.
|
||||||
|
T('settembre.hero.badge', 'insanity.lab · bologna savena'),
|
||||||
|
T('settembre.hero.title1', 'Allenati con metodo.'),
|
||||||
|
T('settembre.hero.accent', 'Scegli come'),
|
||||||
|
T('settembre.hero.title2', 'vuoi farlo.'),
|
||||||
|
H('settembre.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
|
||||||
|
T('settembre.hero.cta', 'Le nostre proposte', []),
|
||||||
|
|
||||||
|
T('settembre.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
|
||||||
|
T('settembre.launch.title', 'Il nostro metodo nasce per durare.'),
|
||||||
|
T('settembre.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
|
||||||
|
T('settembre.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte — e una valutazione singola, per chi vuole partire dai propri numeri."),
|
||||||
|
|
||||||
|
T('settembre.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
|
||||||
|
|
||||||
|
T('settembre.buy-label', 'Acquista', []),
|
||||||
|
|
||||||
|
T('settembre.card.1.eyebrow', 'Personal training', []),
|
||||||
|
T('settembre.card.1.title', 'One to One'),
|
||||||
|
T('settembre.card.1.price', '250 €', []),
|
||||||
|
T('settembre.card.1.unit', '5 incontri', []),
|
||||||
|
T('settembre.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||||
|
T('settembre.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
||||||
|
T('settembre.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
|
||||||
|
T('settembre.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||||
|
|
||||||
|
T('settembre.card.2.eyebrow', 'Personal training', []),
|
||||||
|
T('settembre.card.2.title', 'Personalized Training Group'),
|
||||||
|
T('settembre.card.2.price', '150 €', []),
|
||||||
|
T('settembre.card.2.unit', '5 incontri', []),
|
||||||
|
T('settembre.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||||
|
T('settembre.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
||||||
|
T('settembre.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
|
||||||
|
T('settembre.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||||
|
|
||||||
|
T('settembre.card.3.eyebrow', 'Pilates class', []),
|
||||||
|
T('settembre.card.3.title', 'Reformer Class'),
|
||||||
|
T('settembre.card.3.price', '130 €', []),
|
||||||
|
T('settembre.card.3.unit', '5 incontri', []),
|
||||||
|
T('settembre.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
|
||||||
|
T('settembre.card.3.feature.2', '40 minuti, massimo 5 utenti'),
|
||||||
|
T('settembre.card.3.feature.3', 'Lavoro a basso impatto sulle articolazioni'),
|
||||||
|
|
||||||
|
T('settembre.card.4.eyebrow', 'Holistic class', []),
|
||||||
|
T('settembre.card.4.title', 'Pilates Mat, Mobility, Yoga'),
|
||||||
|
T('settembre.card.4.price', '80 €', []),
|
||||||
|
T('settembre.card.4.unit', '5 incontri', []),
|
||||||
|
T('settembre.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
|
||||||
|
T('settembre.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
|
||||||
|
T('settembre.card.4.feature.3', 'Adatto a tutti i livelli'),
|
||||||
|
|
||||||
|
// Il Check-up avanzato non è un percorso: si compra una volta e si fa in un appuntamento.
|
||||||
|
// Per questo l'unità dice "1 valutazione" e non "5 incontri".
|
||||||
|
T('settembre.card.5.eyebrow', 'Valutazione', []),
|
||||||
|
T('settembre.card.5.title', 'Check-up avanzato'),
|
||||||
|
T('settembre.card.5.price', '70 €', []),
|
||||||
|
T('settembre.card.5.unit', '1 valutazione', []),
|
||||||
|
T('settembre.card.5.feature.1', 'Analisi completa di composizione corporea, e analisi HRV'),
|
||||||
|
T('settembre.card.5.feature.2', 'Spirometria e scansione posturale 3D'),
|
||||||
|
T('settembre.card.5.feature.3', 'Report dettagliato e consigli personalizzati'),
|
||||||
|
|
||||||
|
// La deadline è lo stesso istante in cui finisce l'edizione in `promo-edizioni.ts`: un test
|
||||||
|
// lo verifica, perché un countdown che scade a un'ora diversa dallo scambio fa contraddire
|
||||||
|
// il sito.
|
||||||
|
T('settembre.countdown.label', 'La promo scade tra', []),
|
||||||
|
T('settembre.countdown.deadline', '2026-09-19T23:59:59+02:00', []),
|
||||||
|
T('settembre.countdown.unit.days', 'giorni', []),
|
||||||
|
T('settembre.countdown.unit.hours', 'ore', []),
|
||||||
|
T('settembre.countdown.unit.minutes', 'minuti', []),
|
||||||
|
T('settembre.countdown.unit.seconds', 'secondi', []),
|
||||||
|
|
||||||
|
T('settembre.validity-note', 'Promo valida dal 5 al 19 settembre 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
|
||||||
|
|
||||||
|
T('settembre.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
|
||||||
|
T('settembre.faq.0.q', 'Come faccio ad acquistare i pacchetti?'),
|
||||||
|
H('settembre.faq.0.a', 'Clicca sull\'icona del pacchetto che desideri acquistare e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona quello che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
|
||||||
|
T('settembre.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'),
|
||||||
|
H('settembre.faq.1.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
|
||||||
|
// Ad agosto qui c'era scritto "gli orari saranno pubblicati entro Settembre": adesso è
|
||||||
|
// settembre e il calendario esiste, quindi la frase andava riscritta o diventava falsa.
|
||||||
|
T('settembre.faq.2.q', 'Come funziona il percorso Reformer Class o Holistic Class?'),
|
||||||
|
H('settembre.faq.2.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class li trovi nel <span class="hl">calendario</span>, che puoi consultare e scaricare in PDF dalla pagina <span class="hl">Servizi</span> del sito. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
|
||||||
|
T('settembre.faq.7.q', "Che cos'è il Check-up avanzato?"),
|
||||||
|
H('settembre.faq.7.a', 'È una <span class="hl">valutazione singola</span>, non un percorso a incontri: analisi completa della composizione corporea e analisi HRV, spirometria e scansione posturale 3D. Al termine ricevi un report dettagliato con i consigli che ne derivano. Puoi farlo da solo, oppure come punto di partenza prima di scegliere un percorso. Per fissare l\'appuntamento scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span>.'),
|
||||||
|
T('settembre.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
|
||||||
|
H('settembre.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Personalized Training Group</span>, <span class="hl">Reformer Class</span> o <span class="hl">Pilates Mat, Mobility, Yoga</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno. Il <span class="hl">Check-up avanzato</span> è a sé: è una valutazione singola e non un pacchetto di incontri.'),
|
||||||
|
T('settembre.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
|
||||||
|
H('settembre.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
|
||||||
|
T('settembre.faq.5.q', 'Il pagamento è sicuro?'),
|
||||||
|
H('settembre.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
|
||||||
|
T('settembre.faq.6.q', 'Posso chiedere un rimborso?'),
|
||||||
|
H('settembre.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
|
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export interface Service {
|
export interface Service {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Override del titolo mostrato nell'hero (H1) della pagina dettaglio.
|
||||||
|
* Se assente usa `title`. Serve quando l'hero deve differire dall'H2/card. */
|
||||||
|
heroTitle?: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
longDescription: string;
|
longDescription: string;
|
||||||
@@ -10,12 +13,25 @@ export interface Service {
|
|||||||
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
|
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
|
||||||
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
|
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
|
||||||
grid?: boolean;
|
grid?: boolean;
|
||||||
|
/** Dove porta la card (foto e "Per saperne di più"). Assente = la scheda del servizio.
|
||||||
|
* Serve per le card che instradano a una vista di /training invece che a una scheda. */
|
||||||
|
href?: string;
|
||||||
|
/** false = nessuna pagina dettaglio: /services/<slug> risponde 404.
|
||||||
|
* La voce resta in griglia con il proprio `href`. */
|
||||||
|
page?: boolean;
|
||||||
|
/** Sezione in coda alla scheda, oltre alla descrizione principale.
|
||||||
|
* Tag: service.<slug>.extra.title | .body | .item.N */
|
||||||
|
extra?: { title: string; body: string; items: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const services: Service[] = [
|
export const services: Service[] = [
|
||||||
{
|
{
|
||||||
slug: "one-to-one",
|
slug: "one-to-one",
|
||||||
title: "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",
|
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.",
|
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.",
|
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.",
|
||||||
@@ -29,13 +45,13 @@ export const services: Service[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "small-group",
|
slug: "small-group",
|
||||||
title: "Small Group",
|
title: "Personalized Training Group",
|
||||||
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
|
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
|
||||||
excerpt: "Lo Small Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.",
|
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 small group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.",
|
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: [],
|
features: [],
|
||||||
faq: [
|
faq: [
|
||||||
{ q: "Quante persone ci sono in un Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
|
{ q: "Quante persone ci sono in un Personalized Training Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
|
||||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
{ q: "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." },
|
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
|
||||||
],
|
],
|
||||||
@@ -43,8 +59,14 @@ export const services: Service[] = [
|
|||||||
grid: false,
|
grid: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// La Performance Class non si fa più (cliente, 17/08/2026): la scheda che la spiegava
|
||||||
|
// sparisce (`page: false` → /services/performance-class risponde 404) mentre la card
|
||||||
|
// "Class" resta in griglia e porta alla vista Training con Holistic e Reformer.
|
||||||
|
// Il pricing è caduto con la scheda: era l'unico servizio ad averlo.
|
||||||
slug: "performance-class",
|
slug: "performance-class",
|
||||||
title: "Class",
|
title: "Class",
|
||||||
|
href: "/training/class",
|
||||||
|
page: false,
|
||||||
subtitle: "Gruppo max 8 persone",
|
subtitle: "Gruppo max 8 persone",
|
||||||
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
||||||
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
||||||
@@ -57,7 +79,7 @@ export const services: Service[] = [
|
|||||||
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
|
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
|
||||||
{ q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
|
{ q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
|
||||||
],
|
],
|
||||||
hasPricing: true,
|
hasPricing: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "coaching",
|
slug: "coaching",
|
||||||
@@ -77,16 +99,28 @@ export const services: Service[] = [
|
|||||||
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
|
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
|
||||||
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
|
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
|
||||||
slug: "profilazione",
|
slug: "profilazione",
|
||||||
title: "Profilazione",
|
title: "Check-up",
|
||||||
subtitle: "Check-up e valutazione fisica",
|
subtitle: "Base e avanzato",
|
||||||
excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.",
|
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.",
|
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"],
|
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
|
||||||
faq: [
|
faq: [
|
||||||
{ q: "Quando si fa la profilazione?", a: "All’inizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
|
{ q: "Quando si fa la profilazione?", a: "All’inizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
|
||||||
{ q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." },
|
|
||||||
],
|
],
|
||||||
hasPricing: false,
|
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).
|
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
|
||||||
|
|||||||
@@ -18,22 +18,20 @@ export const site = {
|
|||||||
{ label: "Blog & Edugo", href: "/blog" },
|
{ label: "Blog & Edugo", href: "/blog" },
|
||||||
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
||||||
{ label: "Contatti", href: "/contact" },
|
{ label: "Contatti", href: "/contact" },
|
||||||
// Pagina di test: visibile solo agli admin loggati, nascosta al pubblico.
|
|
||||||
{ label: "Promo 2 (test)", href: "/promo-eventi2", adminOnly: true },
|
|
||||||
],
|
],
|
||||||
footerTraining: [
|
footerTraining: [
|
||||||
{ label: "One to one", href: "/training#one-to-one" },
|
{ label: "One to one", href: "/training#one-to-one" },
|
||||||
{ label: "Small group", href: "/training#small-groups" },
|
{ label: "Personalized Training Group", href: "/training#small-groups" },
|
||||||
{ label: "Rehab", href: "/training#rehab" },
|
{ label: "Rehab", href: "/training#rehab" },
|
||||||
{ label: "Holistic class", href: "/training#holistic-class" },
|
{ label: "Holistic class", href: "/training#holistic-class" },
|
||||||
{ label: "Reformer class", href: "/training#reformer-class" },
|
{ label: "Reformer class", href: "/training#reformer-class" },
|
||||||
{ label: "Coaching", href: "/training#coaching" },
|
{ label: "Coaching", href: "/training#coaching" },
|
||||||
],
|
],
|
||||||
footerServices: [
|
footerServices: [
|
||||||
{ label: "Personal Training", href: "/services/personal-training" },
|
{ label: "Personal Training", href: "/training/one-to-one" },
|
||||||
{ label: "Class", href: "/services/performance-class" },
|
{ label: "Class", href: "/training/class" },
|
||||||
{ label: "Coaching", href: "/services/coaching" },
|
{ label: "Coaching", href: "/services/coaching" },
|
||||||
{ label: "Profilazione", href: "/services/profilazione" },
|
{ label: "Check-up", href: "/services/profilazione" },
|
||||||
{ label: "Osteopatia", href: "/services/osteopatia" },
|
{ label: "Osteopatia", href: "/services/osteopatia" },
|
||||||
{ label: "Nutrizione", href: "/services/nutrition" },
|
{ label: "Nutrizione", href: "/services/nutrition" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
export const trainings = [
|
export const trainings = [
|
||||||
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } },
|
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } },
|
||||||
{ id: "small-groups", title: "Small Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } },
|
{ id: "small-groups", title: "Personalized Training Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. 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: "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: "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: "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" } },
|
{ id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le viste di /training: la pagina mostra tutti i training, oppure il solo sottoinsieme
|
||||||
|
* chiesto dal cliente (documento MODIFICHE_PER_SITO.pdf del 17/08/2026).
|
||||||
|
*
|
||||||
|
* Da /services la card «One to One» porta a One to One / Personalized Training Group / Rehab,
|
||||||
|
* la card «Class» a Holistic Class / Reformer Class: chi clicca cerca quella famiglia di
|
||||||
|
* servizi, e vedersi comparire l'elenco intero costringe a cercare di nuovo.
|
||||||
|
*/
|
||||||
|
export const VISTE_TRAINING: Record<string, { titolo: string; blocchi: readonly string[] }> = {
|
||||||
|
'one-to-one': { titolo: 'Allenamento seguito', blocchi: ['one-to-one', 'small-groups', 'rehab'] },
|
||||||
|
class: { titolo: 'Class', blocchi: ['holistic-class', 'reformer-class'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I training da mostrare per una vista. `undefined` = pagina intera.
|
||||||
|
*
|
||||||
|
* Una vista sconosciuta restituisce `null`, e la pagina risponde 404: mostrare l'elenco
|
||||||
|
* completo al posto di un filtro sbagliato farebbe passare per buono un indirizzo rotto —
|
||||||
|
* il link continuerebbe a "funzionare" mentre porta altrove.
|
||||||
|
*/
|
||||||
|
export function trainingDaMostrare(vista?: string): typeof trainings[number][] | null {
|
||||||
|
if (!vista) return [...trainings];
|
||||||
|
const v = VISTE_TRAINING[vista];
|
||||||
|
if (!v) return null;
|
||||||
|
return v.blocchi.map((id) => trainings.find((t) => t.id === id)!).filter(Boolean);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
---
|
---
|
||||||
|
// Layout dell'area riservata. Usa i token e i font del sito, come il Campus e Stress Index:
|
||||||
|
// il pannello non è più un foglio di stile a sé, così chi passa dal sito all'amministrazione
|
||||||
|
// resta nello stesso mondo visivo.
|
||||||
|
import '@fontsource/montserrat/400.css';
|
||||||
|
import '@fontsource/montserrat/500.css';
|
||||||
|
import '@fontsource/montserrat/600.css';
|
||||||
|
import '@fontsource/open-sans/400.css';
|
||||||
|
import '@fontsource/open-sans/600.css';
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
interface Props { title: string }
|
interface Props { title: string }
|
||||||
const { title } = Astro.props;
|
const { title } = Astro.props;
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
|
const path = Astro.url.pathname;
|
||||||
|
const isManager = user?.role === 'superuser' || user?.role === 'admin';
|
||||||
|
|
||||||
|
// Voci del menu: ognuna compare solo a chi può davvero aprirla.
|
||||||
|
const VOCI = [
|
||||||
|
{ href: '/admin', label: 'Articoli', visibile: true, exact: true },
|
||||||
|
{ href: '/admin/content', label: 'Contenuti', visibile: isManager },
|
||||||
|
{ href: '/admin/users', label: 'Utenti', visibile: user?.role === 'admin' },
|
||||||
|
];
|
||||||
|
const attiva = (v: { href: string; exact?: boolean }) =>
|
||||||
|
v.exact ? path === v.href : path.startsWith(v.href);
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="it">
|
<html lang="it">
|
||||||
@@ -10,40 +31,160 @@ const user = Astro.locals.user;
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{title} — Admin InsanityLab</title>
|
<title>{title} — Admin InsanityLab</title>
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<style is:global>
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
|
|
||||||
.abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
|
|
||||||
.awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
|
|
||||||
.abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
|
|
||||||
.abtn--danger { background: #a33; }
|
|
||||||
.afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
|
|
||||||
table { width: 100%; border-collapse: collapse; background: #fff; }
|
|
||||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
|
|
||||||
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
|
|
||||||
.pill--draft { background: #f3d9a4; }
|
|
||||||
.form-msg { font-size: .9rem; margin-top: 8px; }
|
|
||||||
.form-msg--ok { color: #3c7a3c; }
|
|
||||||
.form-msg--err { color: #b03030; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="ad">
|
||||||
<div class="abar">
|
<header class="abar">
|
||||||
<strong>InsanityLab · Admin</strong>
|
<a class="abar__brand" href="/admin">
|
||||||
<span>
|
<span class="abar__eyebrow">InsanityLab</span>
|
||||||
|
Area riservata
|
||||||
|
</a>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<nav class="abar__nav" aria-label="pannello">
|
||||||
{`Ciao, ${user.username}`}
|
{VOCI.filter((v) => v.visibile).map((v) => (
|
||||||
<a href="/admin">Articoli</a>
|
<a href={v.href} class:list={['abar__link', { 'is-active': attiva(v) }]}>{v.label}</a>
|
||||||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
|
))}
|
||||||
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
|
<a class="abar__link" href="/" target="_blank" rel="noopener">Vai a sito</a>
|
||||||
<a href="/admin/new">Nuovo</a>
|
<a class="abar__link abar__link--out" href="/admin/logout">Esci</a>
|
||||||
<a href="/admin/logout">Esci</a>
|
</nav>
|
||||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
{user && <p class="abar__user">{user.username}</p>}
|
||||||
<div class="awrap"><slot /></div>
|
</header>
|
||||||
|
|
||||||
|
<main class="awrap"><slot /></main>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
body.ad { background: var(--c-bg-alt); }
|
||||||
|
|
||||||
|
/* --- barra --- */
|
||||||
|
.abar {
|
||||||
|
display: flex; align-items: center; gap: 28px; flex-wrap: wrap;
|
||||||
|
background: var(--c-dark); color: #cfc6bd; padding: 16px 32px;
|
||||||
|
}
|
||||||
|
.abar__brand {
|
||||||
|
font-family: var(--font-heading); font-size: 1rem; font-weight: 600; letter-spacing: .12em;
|
||||||
|
text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.15;
|
||||||
|
}
|
||||||
|
.abar__eyebrow { display: block; font-size: .62rem; letter-spacing: .2em; color: var(--c-accent); }
|
||||||
|
.abar__nav { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
|
||||||
|
.abar__link {
|
||||||
|
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .16em;
|
||||||
|
text-transform: uppercase; color: #b6ada3; text-decoration: none; padding: 4px 0;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.abar__link:hover { color: #fff; }
|
||||||
|
.abar__link.is-active { color: #fff; border-bottom-color: var(--c-accent); }
|
||||||
|
.abar__link--out { color: var(--c-accent); }
|
||||||
|
.abar__user {
|
||||||
|
margin: 0 0 0 auto; font-size: .74rem; letter-spacing: .12em; text-transform: uppercase; color: #8d8379;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- contenuto --- */
|
||||||
|
.awrap { max-width: 1080px; margin: 40px auto 80px; padding: 0 24px; }
|
||||||
|
.awrap h1 {
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.1rem); text-transform: none; letter-spacing: .02em; margin-bottom: .6em;
|
||||||
|
}
|
||||||
|
.awrap h2 { font-size: 1.15rem; text-transform: none; letter-spacing: .02em; }
|
||||||
|
|
||||||
|
/* --- comandi --- */
|
||||||
|
.abtn {
|
||||||
|
display: inline-block; background: var(--c-dark); color: #fff; border: 1px solid var(--c-dark);
|
||||||
|
padding: 10px 20px; cursor: pointer; text-decoration: none; font-family: var(--font-heading);
|
||||||
|
font-size: .7rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.abtn:hover { background: var(--c-dark-2); border-color: var(--c-dark-2); }
|
||||||
|
.abtn--danger { background: none; color: #a4544a; border-color: #ddcfc9; }
|
||||||
|
.abtn--danger:hover { background: #a4544a; border-color: #a4544a; color: #fff; }
|
||||||
|
|
||||||
|
.afield {
|
||||||
|
width: 100%; padding: 11px 13px; margin-bottom: 14px; border: 1px solid #ddd6cb;
|
||||||
|
box-sizing: border-box; font: inherit; font-size: .92rem; background: #fff; border-radius: 0;
|
||||||
|
}
|
||||||
|
.afield:focus { outline: 2px solid var(--c-accent); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* --- tabelle --- */
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e4dfd7; }
|
||||||
|
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid #efeae2; font-size: .9rem; }
|
||||||
|
th {
|
||||||
|
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .14em;
|
||||||
|
text-transform: uppercase; color: var(--c-text-light); background: #faf8f5;
|
||||||
|
}
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
tbody tr:hover { background: #faf8f5; }
|
||||||
|
td a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
|
||||||
|
td a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block; font-family: var(--font-heading); font-size: .64rem; font-weight: 600;
|
||||||
|
letter-spacing: .1em; text-transform: uppercase; padding: 3px 10px;
|
||||||
|
background: rgba(111,143,106,.16); color: #5f7c5a;
|
||||||
|
}
|
||||||
|
.pill--draft { background: rgba(192,138,62,.18); color: #96682a; }
|
||||||
|
|
||||||
|
.atoolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.amuted { color: var(--c-text-light); font-size: .9rem; }
|
||||||
|
|
||||||
|
.aform { max-width: 420px; background: #fff; border: 1px solid #e4dfd7; padding: 26px 24px; }
|
||||||
|
.alabel {
|
||||||
|
display: block; margin-bottom: 6px; font-family: var(--font-heading); font-size: .68rem;
|
||||||
|
font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light);
|
||||||
|
}
|
||||||
|
.ahint { margin: -8px 0 16px; font-size: .78rem; color: var(--c-text-light); }
|
||||||
|
.aactions { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
|
||||||
|
.abtn--ghost { background: none; color: var(--c-heading); border-color: #ddd6cb; }
|
||||||
|
.abtn--ghost:hover { background: none; border-color: var(--c-accent); color: var(--c-accent-dark); }
|
||||||
|
|
||||||
|
/* Ruolo: una pillola che resta un <select>, con la freccia disegnata a parte perché
|
||||||
|
quella di sistema non si può vestire. */
|
||||||
|
.arole {
|
||||||
|
appearance: none; -webkit-appearance: none; border: 1px solid #e0d9cd; background-color: #faf8f5;
|
||||||
|
padding: 6px 30px 6px 12px; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
||||||
|
letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); cursor: pointer;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%238a8a8a' stroke-width='1.6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 11px center; background-size: 9px;
|
||||||
|
}
|
||||||
|
.arole:hover { border-color: var(--c-accent); }
|
||||||
|
.arole:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
|
||||||
|
.arole[data-role='admin'] { background-color: #efe7dc; border-color: #d9c7ab; color: #6b5334; }
|
||||||
|
.arole[data-role='superuser'] { background-color: #ece7dc; border-color: #d8cfbe; color: #6f6353; }
|
||||||
|
.arole[data-role='piattaforme'] { background-color: #e6ede4; border-color: #cbdcc6; color: #4d6a48; }
|
||||||
|
|
||||||
|
/* Riquadro della password appena generata */
|
||||||
|
.apw { background: #fff; border: 1px solid var(--c-accent); padding: 18px 20px; margin: 0 0 20px; }
|
||||||
|
.apw__label { margin: 0 0 10px; font-size: .85rem; color: var(--c-text-light); }
|
||||||
|
.apw__label strong { color: var(--c-heading); }
|
||||||
|
.apw__row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.apw__row code {
|
||||||
|
background: var(--c-bg-alt); border: 1px solid #e4dfd7; padding: 9px 14px;
|
||||||
|
font-size: 1rem; letter-spacing: .06em; color: var(--c-heading);
|
||||||
|
}
|
||||||
|
.apw__hint { margin: 10px 0 0; font-size: .78rem; color: var(--c-text-light); }
|
||||||
|
.apw__input { width: auto; min-width: 260px; margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */
|
||||||
|
a.aicon { text-decoration: none; }
|
||||||
|
.aicon {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px;
|
||||||
|
padding: 0; background: none; border: 1px solid #ddd6cb; cursor: pointer; color: var(--c-text);
|
||||||
|
}
|
||||||
|
.aicon + .aicon { margin-left: 6px; }
|
||||||
|
.aicon svg { width: 17px; height: 17px; fill: currentColor; }
|
||||||
|
.aicon:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
|
||||||
|
.aicon--danger:hover { border-color: #a4544a; background: #a4544a; color: #fff; }
|
||||||
|
td.uactions, th.uactions { text-align: right; white-space: nowrap; }
|
||||||
|
|
||||||
|
.form-msg { font-size: .88rem; margin-top: 8px; }
|
||||||
|
.form-msg--ok { color: #5f7c5a; }
|
||||||
|
.form-msg--err { color: #a4544a; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.abar { padding: 14px 20px; gap: 14px; }
|
||||||
|
.abar__nav { gap: 16px; width: 100%; }
|
||||||
|
.abar__user { margin-left: 0; }
|
||||||
|
.awrap { margin-top: 26px; padding: 0 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import { randomBytes } from 'node:crypto';
|
|||||||
export const SESSION_COOKIE = 'session';
|
export const SESSION_COOKIE = 'session';
|
||||||
const SESSION_DAYS = 7;
|
const SESSION_DAYS = 7;
|
||||||
|
|
||||||
export type Role = 'admin' | 'superuser' | 'user';
|
// Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole,
|
||||||
|
// così aggiungere un ruolo in futuro è una modifica sola. Prima erano quattro punti
|
||||||
|
// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs -
|
||||||
|
// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta
|
||||||
|
// duplicato: è JavaScript, non può importare da qui, vedi il commento lì).
|
||||||
|
// ⚠️ Solo i ruoli redazionali: `piattaforme`, `cliente` e `trainer` sono passati
|
||||||
|
// all'app delle aree riservate (repo InsanityLab-platforms) insieme alle loro pagine. Un utente
|
||||||
|
// del sito non è un utente di quella, e viceversa: due anagrafiche, due database.
|
||||||
|
export const ROLES = ['user', 'superuser', 'admin'] as const;
|
||||||
|
export type Role = typeof ROLES[number];
|
||||||
|
|
||||||
export function hashPassword(plain: string): string {
|
export function hashPassword(plain: string): string {
|
||||||
return bcrypt.hashSync(plain, 12);
|
return bcrypt.hashSync(plain, 12);
|
||||||
@@ -51,8 +60,9 @@ export function logout(db: Database.Database, token: string): void {
|
|||||||
|
|
||||||
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
|
// 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
|
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
|
||||||
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
|
// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e
|
||||||
// utenti già autenticati).
|
// /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[]][] = [
|
const RULES: [RegExp, Role[]][] = [
|
||||||
[/^\/admin\/users(\/|$)/, ['admin']],
|
[/^\/admin\/users(\/|$)/, ['admin']],
|
||||||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||||||
@@ -65,6 +75,11 @@ export function canAccessAdminPath(role: string, pathname: string): boolean {
|
|||||||
for (const [re, roles] of RULES) {
|
for (const [re, roles] of RULES) {
|
||||||
if (re.test(pathname)) return roles.includes(role as Role);
|
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
|
return true; // blog, upload, logout, showtags: tutti i loggati
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +120,20 @@ export function randomPassword(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isRole(v: unknown): v is Role {
|
export function isRole(v: unknown): v is Role {
|
||||||
return v === 'admin' || v === 'superuser' || v === 'user';
|
return (ROLES as readonly unknown[]).includes(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function landingFor(role: string): string {
|
export function landingFor(role: string): string {
|
||||||
if (role === 'superuser') return '/admin/content';
|
if (role === 'superuser') return '/admin/content';
|
||||||
return '/admin';
|
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,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' });
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ CREATE TABLE IF NOT EXISTS posts (
|
|||||||
draft INTEGER NOT NULL DEFAULT 1,
|
draft INTEGER NOT NULL DEFAULT 1,
|
||||||
published_at TEXT,
|
published_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
-- Cestino: valorizzata quando l'articolo viene rimosso dal sito ma resta recuperabile.
|
||||||
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -177,6 +179,21 @@ export function createDb(path?: string): Database.Database {
|
|||||||
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
|
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
|
||||||
updPromoTx();
|
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,
|
// Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services,
|
||||||
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
|
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
|
||||||
// Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
|
// Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
|
||||||
@@ -221,6 +238,42 @@ export function createDb(path?: string): Database.Database {
|
|||||||
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
|
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); });
|
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
|
||||||
updAboutTx();
|
updAboutTx();
|
||||||
|
// ⚠️ Qui c'era la rinomina una tantum del ruolo `campus` in `piattaforme`. Tolta il
|
||||||
|
// 03/09/2026 con la separazione: quel ruolo non esiste più in questo sito, e la
|
||||||
|
// migrazione avrebbe scritto in `users` un valore che nessuna regola di lib/auth
|
||||||
|
// riconosce. Gli utenti delle aree riservate vivono nel repo InsanityLab-platforms, col loro database.
|
||||||
|
|
||||||
|
// Rimossa la variante di test /promo-eventi2 e la sua voce di menu (era la settima,
|
||||||
|
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
|
||||||
|
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
|
||||||
|
|
||||||
|
// FAQ "Come faccio ad acquistare i pacchetti?": coda con il rimando a WhatsApp aggiunta dopo
|
||||||
|
// il primo deploy → i DB esistenti hanno ancora la versione senza. Append guardato dalla fine
|
||||||
|
// del vecchio testo e dall'assenza della coda → idempotente, non tocca riscritture da pannello.
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE content_blocks SET value = value || '<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'
|
||||||
|
WHERE tag = 'promo.faq.0.a' AND value LIKE '%effettuando il pagamento.'`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
// FAQ 1 e 2 riscritte (rimando alla prima domanda per l'acquisto, screening iniziale per
|
||||||
|
// One to One/Small Group, orari class entro Settembre). Guardia via frammento LIKE del vecchio
|
||||||
|
// testo → idempotente e rispettosa di eventuali riscritture da pannello.
|
||||||
|
const PROMO_FAQ_RISCRITTE: [string, string, string][] = [
|
||||||
|
['promo.faq.1.a', '%per prenotare le singole sessioni in base agli orari disponibili%',
|
||||||
|
'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'],
|
||||||
|
['promo.faq.2.a', '%vai nella schermata Home, clicca il servizio che hai acquistato%',
|
||||||
|
'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app <span class="hl">Bsport</span> e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class saranno pubblicati entro Settembre. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'],
|
||||||
|
];
|
||||||
|
const updFaq = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
|
||||||
|
const updFaqTx = db.transaction(() => { for (const [tag, frag, newV] of PROMO_FAQ_RISCRITTE) updFaq.run(newV, tag, frag); });
|
||||||
|
updFaqTx();
|
||||||
|
|
||||||
|
// Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti.
|
||||||
|
const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
|
||||||
|
if (!colonnePost.some((c) => c.name === 'deleted_at')) {
|
||||||
|
db.exec('ALTER TABLE posts ADD COLUMN deleted_at TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
// 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.
|
// 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 missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface Post {
|
|||||||
id: number; title: string; slug: string; category: string; excerpt: string;
|
id: number; title: string; slug: string; category: string; excerpt: string;
|
||||||
body_html: string; cover: string | null; draft: number; author_id: number | null;
|
body_html: string; cover: string | null; draft: number; author_id: number | null;
|
||||||
published_at: string | null; created_at: string; updated_at: string;
|
published_at: string | null; created_at: string; updated_at: string;
|
||||||
|
/** Momento in cui è finito nel cestino; null se attivo. */
|
||||||
|
deleted_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostInput {
|
export interface PostInput {
|
||||||
@@ -30,6 +32,16 @@ export function updatePost(db: Database.Database, id: number, input: PostInput):
|
|||||||
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sposta nel cestino: l'articolo sparisce dal sito e dall'elenco, ma resta recuperabile. */
|
||||||
|
export function trashPost(db: Database.Database, id: number): void {
|
||||||
|
db.prepare("UPDATE posts SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restorePost(db: Database.Database, id: number): void {
|
||||||
|
db.prepare('UPDATE posts SET deleted_at = NULL WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancellazione definitiva: si usa solo dal cestino. */
|
||||||
export function deletePost(db: Database.Database, id: number): void {
|
export function deletePost(db: Database.Database, id: number): void {
|
||||||
db.prepare('DELETE FROM posts WHERE id = ?').run(id);
|
db.prepare('DELETE FROM posts WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
@@ -39,7 +51,7 @@ export function getPostById(db: Database.Database, id: number): Post | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
|
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
|
||||||
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
|
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0 AND deleted_at IS NULL').get(slug) as Post | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listPublished(
|
export function listPublished(
|
||||||
@@ -47,7 +59,8 @@ export function listPublished(
|
|||||||
opts: { category?: string; page?: number; perPage?: number } = {},
|
opts: { category?: string; page?: number; perPage?: number } = {},
|
||||||
): { items: Post[]; total: number } {
|
): { items: Post[]; total: number } {
|
||||||
const { category, page = 1, perPage = 9 } = opts;
|
const { category, page = 1, perPage = 9 } = opts;
|
||||||
const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
|
const base = 'WHERE draft = 0 AND deleted_at IS NULL';
|
||||||
|
const where = category ? `${base} AND category = @category` : base;
|
||||||
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
|
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
|
||||||
.get({ category }) as { n: number }).n;
|
.get({ category }) as { n: number }).n;
|
||||||
const items = db.prepare(
|
const items = db.prepare(
|
||||||
@@ -56,25 +69,42 @@ export function listPublished(
|
|||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quanti articoli pubblicati ha ogni categoria, cestino escluso. */
|
||||||
|
export function countPublishedByCategory(db: Database.Database): Record<string, number> {
|
||||||
|
const righe = db.prepare(
|
||||||
|
'SELECT category, COUNT(*) AS n FROM posts WHERE draft = 0 AND deleted_at IS NULL GROUP BY category'
|
||||||
|
).all() as { category: string; n: number }[];
|
||||||
|
return Object.fromEntries(righe.map((r) => [r.category, r.n]));
|
||||||
|
}
|
||||||
|
|
||||||
export function listAllPosts(db: Database.Database): Post[] {
|
export function listAllPosts(db: Database.Database): Post[] {
|
||||||
return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
|
return db.prepare('SELECT * FROM posts WHERE deleted_at IS NULL ORDER BY updated_at DESC').all() as Post[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Articoli nel cestino: tutti per chi gestisce, i propri per gli altri. */
|
||||||
|
export function listTrashed(db: Database.Database, authorId?: number): Post[] {
|
||||||
|
return authorId === undefined
|
||||||
|
? db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC').all() as Post[]
|
||||||
|
: db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL AND author_id = ? ORDER BY deleted_at DESC')
|
||||||
|
.all(authorId) as Post[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecent(db: Database.Database, limit = 4): Post[] {
|
export function listRecent(db: Database.Database, limit = 4): Post[] {
|
||||||
return db.prepare(
|
return db.prepare(
|
||||||
'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
|
'SELECT * FROM posts WHERE draft = 0 AND deleted_at IS NULL ORDER BY published_at DESC LIMIT ?'
|
||||||
).all(limit) as Post[];
|
).all(limit) as Post[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
|
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
|
||||||
return db.prepare(
|
return db.prepare(
|
||||||
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
|
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
|
||||||
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
|
FROM posts WHERE draft = 0 AND deleted_at IS NULL GROUP BY month ORDER BY month DESC`
|
||||||
).all() as { month: string; count: number }[];
|
).all() as { month: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
|
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
|
||||||
return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
|
return db.prepare('SELECT * FROM posts WHERE author_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC')
|
||||||
|
.all(authorId) as Post[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
|
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
|
|
||||||
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
|
|
||||||
export function safeNext(next: string | null): string {
|
|
||||||
if (next && next.includes('\\')) return '/';
|
|
||||||
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
|
|
||||||
if (next.startsWith('/api/')) return '/';
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { defineMiddleware } from 'astro:middleware';
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
import { getDb } from './lib/db';
|
import { getDb } from './lib/db';
|
||||||
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth';
|
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor, isProtectedPath } from './lib/auth';
|
||||||
|
|
||||||
export const onRequest = defineMiddleware((context, next) => {
|
export const onRequest = defineMiddleware((context, next) => {
|
||||||
const { pathname } = context.url;
|
const { pathname } = context.url;
|
||||||
const isProtected =
|
if (!isProtectedPath(pathname)) return next();
|
||||||
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
|
|
||||||
pathname.startsWith('/api/admin');
|
|
||||||
if (!isProtected) return next();
|
|
||||||
|
|
||||||
const token = context.cookies.get(SESSION_COOKIE)?.value;
|
const token = context.cookies.get(SESSION_COOKIE)?.value;
|
||||||
const user = token ? getSessionUser(getDb(), token) : null;
|
const user = token ? getSessionUser(getDb(), token) : null;
|
||||||
@@ -17,6 +14,8 @@ export const onRequest = defineMiddleware((context, next) => {
|
|||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ⚠️ Qui resta un solo login, quello del pannello: il login pubblico serviva a portare
|
||||||
|
// i clienti dentro le aree riservate, che dal 03/09/2026 stanno su apps.insanitylab.it.
|
||||||
return context.redirect('/admin/login');
|
return context.redirect('/admin/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
// Cestino degli articoli: qui si recupera ciò che è stato tolto dal sito, o lo si elimina
|
||||||
|
// per sempre. Chi gestisce vede tutto, gli altri solo i propri articoli.
|
||||||
|
import Admin from '../../layouts/Admin.astro';
|
||||||
|
import { getDb } from '../../lib/db';
|
||||||
|
import { listTrashed } from '../../lib/posts';
|
||||||
|
import { listUsers } from '../../lib/auth';
|
||||||
|
export const prerender = false;
|
||||||
|
const user = Astro.locals.user!;
|
||||||
|
const isManager = user.role === 'superuser' || user.role === 'admin';
|
||||||
|
const posts = listTrashed(getDb(), isManager ? undefined : user.id);
|
||||||
|
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
||||||
|
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
---
|
||||||
|
<Admin title="Cestino">
|
||||||
|
<h1>Cestino</h1>
|
||||||
|
<p class="atoolbar"><a class="abtn abtn--ghost" href="/admin">← Torna agli articoli</a></p>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p class="amuted">Il cestino è vuoto.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Nel cestino dal</th><th class="uactions">Azioni</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posts.map((p) => (
|
||||||
|
<tr>
|
||||||
|
<td>{p.title}</td>
|
||||||
|
<td>{p.category}</td>
|
||||||
|
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
|
||||||
|
<td>{p.deleted_at ? fmt.format(new Date(p.deleted_at.replace(' ', 'T') + 'Z')) : '—'}</td>
|
||||||
|
<td class="uactions">
|
||||||
|
<button class="aicon" data-restore={p.id} title="Ripristina" aria-label={`Ripristina ${p.title}`}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="aicon aicon--danger" data-purge={p.id} title="Elimina definitivamente" aria-label={`Elimina definitivamente ${p.title}`}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<p class="form-msg" id="row-msg"></p>
|
||||||
|
</Admin>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const rowMsg = document.getElementById('row-msg')!;
|
||||||
|
const errore = async (res: Response) => {
|
||||||
|
rowMsg.className = 'form-msg form-msg--err';
|
||||||
|
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((b) =>
|
||||||
|
b.addEventListener('click', async () => {
|
||||||
|
const res = await fetch(`/api/admin/posts/${b.dataset.restore}`, { method: 'POST' });
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else errore(res);
|
||||||
|
}));
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-purge]').forEach((b) =>
|
||||||
|
b.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Eliminare definitivamente questo articolo? Il contenuto sarà perso.')) return;
|
||||||
|
const res = await fetch(`/api/admin/posts/${b.dataset.purge}?definitivo=1`, { method: 'DELETE' });
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else errore(res);
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@@ -109,22 +109,26 @@ const allStyles = STYLE_GROUPS as Record<string, readonly string[]>;
|
|||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
.chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
.chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||||
.chead a { color: #7a6a55; }
|
.chead a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
|
||||||
.cgroup { background: #fff; border: 1px solid #ddd; margin-bottom: 14px; }
|
.cgroup { background: #fff; border: 1px solid #e4dfd7; margin-bottom: 14px; }
|
||||||
.cgroup > summary { padding: 12px 16px; cursor: pointer; background: #eee9e1; font-size: 1.05rem; }
|
.cgroup > summary {
|
||||||
.crow { border-top: 1px solid #eee; }
|
padding: 13px 16px; cursor: pointer; background: #faf8f5; border-bottom: 1px solid #efeae2;
|
||||||
|
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .14em;
|
||||||
|
text-transform: uppercase; color: var(--c-heading);
|
||||||
|
}
|
||||||
|
.crow { border-top: 1px solid #efeae2; }
|
||||||
.crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; }
|
.crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; }
|
||||||
.crow > summary code { background: #f0ece5; padding: 1px 6px; font-size: .82rem; white-space: nowrap; }
|
.crow > summary code { background: #f2ede5; padding: 2px 7px; font-size: .78rem; white-space: nowrap; color: #6f6357; }
|
||||||
.crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.crow-body { padding: 6px 16px 12px; }
|
.crow-body { padding: 6px 16px 12px; }
|
||||||
.crow-body .afield { margin-bottom: 8px; }
|
.crow-body .afield { margin-bottom: 8px; }
|
||||||
.cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; }
|
.cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; }
|
||||||
.cstyles label { font-size: .85rem; color: #555; }
|
.cstyles label { font-size: .82rem; color: var(--c-text-light); }
|
||||||
.cstyles select { margin-left: 4px; padding: 4px; font: inherit; }
|
.cstyles select { margin-left: 4px; padding: 4px; font: inherit; }
|
||||||
.cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
.cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
.cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #ddd; }
|
.cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #e4dfd7; }
|
||||||
.cmeta { color: #999; font-size: .78rem; margin: 4px 0 0; }
|
.cmeta { color: var(--c-text-light); font-size: .76rem; margin: 4px 0 0; }
|
||||||
.crow--hl { outline: 2px solid #b5a48b; }
|
.crow--hl { outline: 2px solid var(--c-accent); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
---
|
---
|
||||||
import Admin from '../../layouts/Admin.astro';
|
import Admin from '../../layouts/Admin.astro';
|
||||||
import { getDb } from '../../lib/db';
|
import { getDb } from '../../lib/db';
|
||||||
import { listAllPosts, listByAuthor } from '../../lib/posts';
|
import { listAllPosts, listByAuthor, listTrashed } from '../../lib/posts';
|
||||||
import { listUsers } from '../../lib/auth';
|
import { listUsers } from '../../lib/auth';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const user = Astro.locals.user!;
|
const user = Astro.locals.user!;
|
||||||
const isManager = user.role === 'superuser' || user.role === 'admin';
|
const isManager = user.role === 'superuser' || user.role === 'admin';
|
||||||
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
|
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
|
||||||
|
const cestinati = listTrashed(getDb(), isManager ? undefined : user.id).length;
|
||||||
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
||||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
---
|
---
|
||||||
<Admin title="Articoli">
|
<Admin title="Articoli">
|
||||||
<h1>Articoli</h1>
|
<h1>Articoli</h1>
|
||||||
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
|
|
||||||
|
<p class="atoolbar">
|
||||||
|
<a class="abtn" href="/admin/new">+ Nuovo articolo</a>
|
||||||
|
{cestinati > 0 && <a class="abtn abtn--ghost" href="/admin/cestino">Cestino ({cestinati})</a>}
|
||||||
|
</p>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
|
<thead>
|
||||||
|
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th class="uactions">Azioni</th></tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{posts.map((p) => (
|
{posts.map((p) => (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -23,20 +31,33 @@ const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 's
|
|||||||
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
|
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
|
||||||
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
|
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
|
||||||
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
|
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
|
||||||
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
|
<td class="uactions">
|
||||||
|
<a class="aicon" href={`/admin/edit/${p.id}`} title="Modifica" aria-label={`Modifica ${p.title}`}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 17 9.5-9.5 3 3L7 20H4v-3Zm11.7-11.7 1.6-1.6a1.4 1.4 0 0 1 2 0l1 1a1.4 1.4 0 0 1 0 2l-1.6 1.6-3-3Z"/></svg>
|
||||||
|
</a>
|
||||||
|
<button class="aicon aicon--danger" data-del={p.id} title="Sposta nel cestino" aria-label={`Sposta ${p.title} nel cestino`}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
|
{posts.length === 0 && <p class="amuted">Nessun articolo: creane uno nuovo.</p>}
|
||||||
|
<p class="form-msg" id="row-msg"></p>
|
||||||
</Admin>
|
</Admin>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const rowMsg = document.getElementById('row-msg')!;
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', async () => {
|
||||||
if (!confirm('Eliminare definitivamente questo articolo?')) return;
|
if (!confirm('Spostare questo articolo nel cestino? Sparirà dal sito, ma potrai recuperarlo.')) return;
|
||||||
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
||||||
if (res.ok) location.reload();
|
if (res.ok) location.reload();
|
||||||
else alert('Eliminazione non riuscita.');
|
else {
|
||||||
|
rowMsg.className = 'form-msg form-msg--err';
|
||||||
|
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,40 @@
|
|||||||
---
|
---
|
||||||
import Admin from '../../layouts/Admin.astro';
|
import Admin from '../../layouts/Admin.astro';
|
||||||
import { getDb } from '../../lib/db';
|
import { getDb } from '../../lib/db';
|
||||||
import { login, SESSION_COOKIE } from '../../lib/auth';
|
import { login, getSessionUser, logout, landingFor, isRole, SESSION_COOKIE } from '../../lib/auth';
|
||||||
import { rateLimit } from '../../lib/rate-limit';
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
let error = '';
|
let error = '';
|
||||||
if (Astro.request.method === 'POST') {
|
if (Astro.request.method === 'POST') {
|
||||||
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
|
|
||||||
error = 'Troppi tentativi: riprova tra 15 minuti.';
|
|
||||||
} else {
|
|
||||||
const form = await Astro.request.formData();
|
const form = await Astro.request.formData();
|
||||||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||||||
if (token) {
|
if (token) {
|
||||||
|
const utente = getSessionUser(getDb(), token);
|
||||||
|
// Un account delle aree riservate rimasto in questo database (i ruoli piattaforme,
|
||||||
|
// cliente e trainer sono emigrati il 03/09/2026) non deve entrare qui.
|
||||||
|
// Senza questo controllo la password sarebbe giusta, la sessione verrebbe creata, e
|
||||||
|
// poi il middleware lo rimbalzerebbe su landingFor -> /admin -> di nuovo il middleware:
|
||||||
|
// un CICLO di redirect, che sembra un guasto del sito invece di un account nel posto
|
||||||
|
// sbagliato. La sessione appena creata va chiusa, non lasciata aperta a vuoto.
|
||||||
|
if (!utente || !isRole(utente.role)) {
|
||||||
|
logout(getDb(), token);
|
||||||
|
error = 'Questo account appartiene all area riservata: si accede da apps.insanitylab.it.';
|
||||||
|
} else {
|
||||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||||
httpOnly: true, sameSite: 'lax', path: '/',
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||||
});
|
});
|
||||||
return Astro.redirect('/admin');
|
return Astro.redirect(landingFor(utente.role));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
error = 'Credenziali non valide.';
|
error = 'Credenziali non valide.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
<Admin title="Login">
|
<Admin title="Login">
|
||||||
<h1>Accesso</h1>
|
<h1>Accesso</h1>
|
||||||
{error && <p style="color:#a33">{error}</p>}
|
{error && <p class="form-msg form-msg--err">{error}</p>}
|
||||||
<form method="post" style="max-width:360px">
|
<form class="aform" method="post">
|
||||||
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
|
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
|
||||||
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||||
<button class="abtn" type="submit">Entra</button>
|
<button class="abtn" type="submit">Entra</button>
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export const GET: APIRoute = ({ cookies, redirect }) => {
|
|||||||
const token = cookies.get(SESSION_COOKIE)?.value;
|
const token = cookies.get(SESSION_COOKIE)?.value;
|
||||||
if (token) logout(getDb(), token);
|
if (token) logout(getDb(), token);
|
||||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||||
return redirect('/admin/login');
|
return redirect('/');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Admin from '../../layouts/Admin.astro';
|
import Admin from '../../layouts/Admin.astro';
|
||||||
import { getDb } from '../../lib/db';
|
import { getDb } from '../../lib/db';
|
||||||
import { listUsers } from '../../lib/auth';
|
import { listUsers, ROLES } from '../../lib/auth';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const users = listUsers(getDb());
|
const users = listUsers(getDb());
|
||||||
const me = Astro.locals.user!;
|
const me = Astro.locals.user!;
|
||||||
@@ -9,34 +9,54 @@ const me = Astro.locals.user!;
|
|||||||
<Admin title="Utenti">
|
<Admin title="Utenti">
|
||||||
<h1>Utenti</h1>
|
<h1>Utenti</h1>
|
||||||
|
|
||||||
<h2>Nuovo utente</h2>
|
<p><a class="abtn" href="/admin/users/new">+ Nuovo utente</a></p>
|
||||||
<form id="new-user" style="max-width:420px">
|
|
||||||
<input class="afield" name="username" placeholder="Nome utente" required />
|
|
||||||
<input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
|
|
||||||
<select class="afield" name="role">
|
|
||||||
<option value="user">user</option>
|
|
||||||
<option value="superuser">superuser</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
<button class="abtn" type="submit">Crea</button>
|
|
||||||
<span class="form-msg" id="new-msg"></span>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2 style="margin-top:30px">Elenco</h2>
|
<!-- Pannello password: si apre dall'icona a chiave della riga. Permette di scegliere una
|
||||||
|
password o di farla generare; il risultato si vede una volta sola, perché nel database
|
||||||
|
finisce solo il suo hash. -->
|
||||||
|
<div class="apw" id="pwbox" hidden>
|
||||||
|
<p class="apw__label">Password di <strong id="pwbox-user"></strong></p>
|
||||||
|
|
||||||
|
<div id="pwbox-form">
|
||||||
|
<div class="apw__row">
|
||||||
|
<input class="afield apw__input" type="text" id="pwbox-input" placeholder="Scrivi una password (min 8)" autocomplete="off" />
|
||||||
|
<button class="abtn" type="button" id="pwbox-set">Imposta</button>
|
||||||
|
<button class="abtn abtn--ghost" type="button" id="pwbox-gen">Genera casuale</button>
|
||||||
|
<button class="abtn abtn--ghost" type="button" id="pwbox-close">Annulla</button>
|
||||||
|
</div>
|
||||||
|
<p class="apw__hint">La password attuale smetterà di funzionare.</p>
|
||||||
|
<p class="form-msg" id="pwbox-msg" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pwbox-result" hidden>
|
||||||
|
<div class="apw__row">
|
||||||
|
<code id="pwbox-value"></code>
|
||||||
|
<button class="abtn" type="button" id="pwbox-copy">Copia</button>
|
||||||
|
<button class="abtn abtn--ghost" type="button" id="pwbox-done">Chiudi</button>
|
||||||
|
</div>
|
||||||
|
<p class="apw__hint">Consegnala ora: chiuso questo riquadro non è più recuperabile.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
|
<thead><tr><th>Utente</th><th>Ruolo</th><th class="uactions">Azioni</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr data-id={u.id}>
|
<tr data-id={u.id}>
|
||||||
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||||||
<td>
|
<td>
|
||||||
<select class="role-sel" data-id={u.id}>
|
<select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}>
|
||||||
{['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
{ROLES.map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="uactions">
|
||||||
<button class="abtn" data-reset={u.id}>Reset password</button>
|
<button class="aicon" data-reset={u.id} title="Genera una nuova password" aria-label={`Genera una nuova password per ${u.username}`}>
|
||||||
{u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 7a4 4 0 1 1-3.9 5H7v2H5v2H2v-3l5.1-5.1A4 4 0 0 1 14 7Z"/><circle cx="15.5" cy="9.5" r="1.2" fill="#fff"/></svg>
|
||||||
|
</button>
|
||||||
|
{u.id !== me.id && (
|
||||||
|
<button class="aicon aicon--danger" data-del={u.id} title="Elimina utente" aria-label={`Elimina ${u.username}`}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -52,37 +72,87 @@ const me = Astro.locals.user!;
|
|||||||
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
|
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('new-user')!.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const f = e.target as HTMLFormElement;
|
|
||||||
const body = {
|
|
||||||
username: (f.elements.namedItem('username') as HTMLInputElement).value,
|
|
||||||
password: (f.elements.namedItem('password') as HTMLInputElement).value,
|
|
||||||
role: (f.elements.namedItem('role') as HTMLSelectElement).value,
|
|
||||||
};
|
|
||||||
const res = await fetch('/api/admin/users', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const msg = document.getElementById('new-msg') as HTMLElement;
|
|
||||||
if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
|
|
||||||
else show(msg, (await res.json()).error ?? 'Errore');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
|
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
|
||||||
sel.addEventListener('change', async () => {
|
sel.addEventListener('change', async () => {
|
||||||
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
|
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
|
||||||
});
|
});
|
||||||
if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
|
if (res.ok) { sel.dataset.role = sel.value; show(rowMsg, 'Ruolo aggiornato', true); }
|
||||||
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
|
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const pwbox = document.getElementById('pwbox')!;
|
||||||
|
const pwUser = document.getElementById('pwbox-user')!;
|
||||||
|
const pwValue = document.getElementById('pwbox-value')!;
|
||||||
|
const pwForm = document.getElementById('pwbox-form')!;
|
||||||
|
const pwResult = document.getElementById('pwbox-result')!;
|
||||||
|
const pwInput = document.getElementById('pwbox-input') as HTMLInputElement;
|
||||||
|
const pwMsg = document.getElementById('pwbox-msg')!;
|
||||||
|
const pwCopy = document.getElementById('pwbox-copy') as HTMLButtonElement;
|
||||||
|
let idCorrente = '';
|
||||||
|
|
||||||
|
const chiudi = () => { pwbox.hidden = true; };
|
||||||
|
document.getElementById('pwbox-close')!.addEventListener('click', chiudi);
|
||||||
|
document.getElementById('pwbox-done')!.addEventListener('click', chiudi);
|
||||||
|
|
||||||
|
/** Manda la nuova password: senza corpo il server ne genera una a caso. */
|
||||||
|
async function cambiaPassword(password?: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/admin/users/${idCorrente}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(password
|
||||||
|
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
pwMsg.hidden = false;
|
||||||
|
pwMsg.className = 'form-msg form-msg--err';
|
||||||
|
pwMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dati = await res.json();
|
||||||
|
pwValue.textContent = dati.password;
|
||||||
|
pwForm.hidden = true;
|
||||||
|
pwResult.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pwbox-set')!.addEventListener('click', () => {
|
||||||
|
const scelta = pwInput.value.trim();
|
||||||
|
if (scelta.length < 8) {
|
||||||
|
pwMsg.hidden = false;
|
||||||
|
pwMsg.className = 'form-msg form-msg--err';
|
||||||
|
pwMsg.textContent = 'Password troppo corta (min 8).';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cambiaPassword(scelta);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pwbox-gen')!.addEventListener('click', () => cambiaPassword());
|
||||||
|
|
||||||
|
pwCopy.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pwValue.textContent ?? '');
|
||||||
|
pwCopy.textContent = 'Copiata';
|
||||||
|
setTimeout(() => { pwCopy.textContent = 'Copia'; }, 1500);
|
||||||
|
} catch {
|
||||||
|
// Senza permesso sugli appunti resta la selezione manuale: evidenziamo il testo.
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(pwValue);
|
||||||
|
getSelection()?.removeAllRanges();
|
||||||
|
getSelection()?.addRange(range);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
|
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', () => {
|
||||||
if (!confirm('Generare una nuova password per questo utente?')) return;
|
idCorrente = b.dataset.reset!;
|
||||||
const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
|
pwUser.textContent = (b.closest('tr')!.querySelector('td')!.textContent ?? '').replace(' (tu)', '');
|
||||||
if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
|
pwInput.value = '';
|
||||||
else show(rowMsg, (await res.json()).error ?? 'Errore');
|
pwMsg.hidden = true;
|
||||||
|
pwResult.hidden = true;
|
||||||
|
pwForm.hidden = false;
|
||||||
|
pwbox.hidden = false;
|
||||||
|
pwbox.scrollIntoView({ block: 'nearest' });
|
||||||
|
pwInput.focus();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
// Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e
|
||||||
|
// il modulo ha spazio per le sue spiegazioni.
|
||||||
|
import Admin from '../../../layouts/Admin.astro';
|
||||||
|
import { ROLES } from '../../../lib/auth';
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina
|
||||||
|
// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto.
|
||||||
|
const DESCRIZIONI: Partial<Record<(typeof ROLES)[number], string>> = {
|
||||||
|
user: 'scrive i propri articoli',
|
||||||
|
superuser: 'articoli e contenuti del sito',
|
||||||
|
admin: 'accesso completo',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
<Admin title="Nuovo utente">
|
||||||
|
<h1>Nuovo utente</h1>
|
||||||
|
|
||||||
|
<form class="aform" id="new-user">
|
||||||
|
<label class="alabel" for="username">Nome utente</label>
|
||||||
|
<input class="afield" id="username" name="username" required autocomplete="off" />
|
||||||
|
|
||||||
|
<label class="alabel" for="password">Password</label>
|
||||||
|
<input class="afield" id="password" name="password" type="text" required minlength="8" autocomplete="off" />
|
||||||
|
<p class="ahint">Almeno 8 caratteri. Resta in chiaro qui: copiala e consegnala alla persona.</p>
|
||||||
|
|
||||||
|
<label class="alabel" for="role">Ruolo</label>
|
||||||
|
<select class="afield" id="role" name="role">
|
||||||
|
{ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="aactions">
|
||||||
|
<button class="abtn" type="submit">Crea utente</button>
|
||||||
|
<a class="abtn abtn--ghost" href="/admin/users">Annulla</a>
|
||||||
|
</div>
|
||||||
|
<p class="form-msg" id="new-msg" hidden></p>
|
||||||
|
</form>
|
||||||
|
</Admin>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('new-user') as HTMLFormElement;
|
||||||
|
const msg = document.getElementById('new-msg') as HTMLElement;
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dati = new FormData(form);
|
||||||
|
const res = await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: String(dati.get('username') ?? ''),
|
||||||
|
password: String(dati.get('password') ?? ''),
|
||||||
|
role: String(dati.get('role') ?? 'user'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = '/admin/users';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msg.hidden = false;
|
||||||
|
msg.className = 'form-msg form-msg--err';
|
||||||
|
msg.textContent = (await res.json()).error ?? 'Creazione non riuscita.';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getDb } from '../../../../lib/db';
|
import { getDb } from '../../../../lib/db';
|
||||||
import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts';
|
import { getPostById, updatePost, deletePost, trashPost, restorePost, canModifyPost } from '../../../../lib/posts';
|
||||||
import { parsePostBody } from '../../../../lib/post-body';
|
import { parsePostBody } from '../../../../lib/post-body';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -25,11 +25,32 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: APIRoute = ({ params, locals }) => {
|
/**
|
||||||
|
* Sposta l'articolo nel cestino. Con `?definitivo=1` lo cancella davvero: è la sola via per
|
||||||
|
* perdere il contenuto, e vale solo per ciò che è già nel cestino.
|
||||||
|
*/
|
||||||
|
export const DELETE: APIRoute = ({ params, locals, url }) => {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
const existing = getPostById(getDb(), id);
|
const existing = getPostById(getDb(), id);
|
||||||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||||||
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
|
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
|
||||||
|
|
||||||
|
if (url.searchParams.get('definitivo') === '1') {
|
||||||
|
if (!existing.deleted_at) return json(400, { error: 'Sposta prima l\'articolo nel cestino.' });
|
||||||
deletePost(getDb(), id);
|
deletePost(getDb(), id);
|
||||||
|
return json(200, { ok: true, definitivo: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
trashPost(getDb(), id);
|
||||||
|
return json(200, { ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Riporta l'articolo fuori dal cestino, nello stato in cui era (bozza o pubblicato). */
|
||||||
|
export const POST: APIRoute = ({ params, locals }) => {
|
||||||
|
const id = Number(params.id);
|
||||||
|
const existing = getPostById(getDb(), id);
|
||||||
|
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||||||
|
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi ripristinare questo articolo.' });
|
||||||
|
restorePost(getDb(), id);
|
||||||
return json(200, { ok: true });
|
return json(200, { ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,27 @@ export const prerender = false;
|
|||||||
const json = (status: number, body: object) =>
|
const json = (status: number, body: object) =>
|
||||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
export const POST: APIRoute = ({ params }) => {
|
/**
|
||||||
|
* Cambia la password di un utente. Senza corpo la genera a caso; con `{ password }` usa
|
||||||
|
* quella scelta dall'admin. In entrambi i casi la risposta la riporta in chiaro una volta
|
||||||
|
* sola: nel database finisce solo il suo hash.
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ params, request }) => {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
|
if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
|
||||||
const password = randomPassword();
|
|
||||||
|
let scelta = '';
|
||||||
|
if (request.headers.get('content-type')?.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const data = (await request.json()) as { password?: unknown };
|
||||||
|
scelta = String(data.password ?? '');
|
||||||
|
} catch {
|
||||||
|
return json(400, { error: 'Richiesta non valida.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scelta && scelta.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
|
||||||
|
|
||||||
|
const password = scelta || randomPassword();
|
||||||
updateUserPassword(getDb(), id, hashPassword(password));
|
updateUserPassword(getDb(), id, hashPassword(password));
|
||||||
// La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna.
|
|
||||||
return json(200, { password });
|
return json(200, { password });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import PageHero from '../../components/PageHero.astro';
|
import PageHero from '../../components/PageHero.astro';
|
||||||
import { getDb } from '../../lib/db';
|
import { getDb } from '../../lib/db';
|
||||||
import { listPublished } from '../../lib/posts';
|
import { listPublished, countPublishedByCategory } from '../../lib/posts';
|
||||||
import { CATEGORIES } from '../../lib/blog-const';
|
import { CATEGORIES } from '../../lib/blog-const';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -15,6 +15,9 @@ const { items, total } = listPublished(getDb(), {
|
|||||||
page, perPage: PER_PAGE,
|
page, perPage: PER_PAGE,
|
||||||
});
|
});
|
||||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||||
|
// Conteggi per categoria: mostrati accanto ai filtri, così si vede subito dove c'è materiale.
|
||||||
|
const perCategoria = countPublishedByCategory(getDb());
|
||||||
|
const totalePubblicati = Object.values(perCategoria).reduce((n, v) => n + v, 0);
|
||||||
const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
|
const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
|
||||||
const linkFor = (cat?: string, p = 1) => {
|
const linkFor = (cat?: string, p = 1) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -33,13 +36,18 @@ const linkFor = (cat?: string, p = 1) => {
|
|||||||
<p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
|
<p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="bfilters" aria-label="categorie">
|
<nav class="bfilters" aria-label="categorie">
|
||||||
<a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>Mostra tutto</a>
|
<a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>
|
||||||
|
Mostra tutto <span class="bfilters__n">({totalePubblicati})</span>
|
||||||
|
</a>
|
||||||
{CATEGORIES.map((c) => (
|
{CATEGORIES.map((c) => (
|
||||||
<a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>{c}</a>
|
<a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>
|
||||||
|
{c} <span class="bfilters__n">({perCategoria[c] ?? 0})</span>
|
||||||
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div id="blog-results">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p style="text-align:center">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
|
<p class="bempty">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="bgrid">
|
<div class="bgrid">
|
||||||
{items.map((post) => (
|
{items.map((post) => (
|
||||||
@@ -61,13 +69,72 @@ const linkFor = (cat?: string, p = 1) => {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Cambio categoria e pagina senza ricaricare: si scarica la stessa pagina e si sostituiscono
|
||||||
|
// solo la griglia e i filtri. I link restano normali, quindi senza JS tutto continua a
|
||||||
|
// funzionare come prima.
|
||||||
|
const risultati = document.getElementById('blog-results');
|
||||||
|
const filtri = document.querySelector('.bfilters');
|
||||||
|
|
||||||
|
if (risultati && filtri) {
|
||||||
|
let inCorso: AbortController | null = null;
|
||||||
|
|
||||||
|
async function mostra(href: string, scorri: boolean): Promise<void> {
|
||||||
|
inCorso?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
inCorso = controller;
|
||||||
|
risultati!.classList.add('is-loading');
|
||||||
|
try {
|
||||||
|
const res = await fetch(href, { signal: controller.signal });
|
||||||
|
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
||||||
|
const nuoviRisultati = doc.getElementById('blog-results');
|
||||||
|
const nuoviFiltri = doc.querySelector('.bfilters');
|
||||||
|
// Se la pagina non ha la forma attesa (errore, redirect) si lascia fare al browser.
|
||||||
|
if (!nuoviRisultati || !nuoviFiltri) { location.href = href; return; }
|
||||||
|
risultati!.innerHTML = nuoviRisultati.innerHTML;
|
||||||
|
filtri!.innerHTML = nuoviFiltri.innerHTML;
|
||||||
|
if (scorri) filtri!.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === 'AbortError') return;
|
||||||
|
location.href = href;
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (inCorso === controller) {
|
||||||
|
risultati!.classList.remove('is-loading');
|
||||||
|
inCorso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delega sul documento: i link vengono rigenerati a ogni cambio, così non serve riagganciarli.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const link = (e.target as Element).closest<HTMLAnchorElement>('.bfilters__item, .bpages__n');
|
||||||
|
if (!link || e.metaKey || e.ctrlKey || e.shiftKey || (e as MouseEvent).button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (link.classList.contains('is-active')) return;
|
||||||
|
history.pushState({}, '', link.href);
|
||||||
|
mostra(link.href, link.classList.contains('bpages__n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avanti/indietro del browser: si ricarica il contenuto senza toccare la cronologia.
|
||||||
|
addEventListener('popstate', () => mostra(location.href, false));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
|
.bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
|
||||||
.bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
|
.bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
|
||||||
.bfilters__item.is-active { background: var(--c-accent); color: #fff; }
|
.bfilters__item.is-active { background: var(--c-accent); color: #fff; }
|
||||||
|
.bfilters__n { color: var(--c-text-light); }
|
||||||
|
.bfilters__item.is-active .bfilters__n { color: rgba(255,255,255,.75); }
|
||||||
|
.bfilters__item { transition: background .2s ease, color .2s ease; }
|
||||||
|
#blog-results { transition: opacity .25s ease; }
|
||||||
|
#blog-results.is-loading { opacity: .35; }
|
||||||
|
.bempty { text-align: center; }
|
||||||
.bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
|
.bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
|
||||||
.bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
|
.bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
|
||||||
.bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
|
.bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari';
|
||||||
|
import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// Il PDF degli orari, come file scaricabile.
|
||||||
|
//
|
||||||
|
// /calendario/tutti.pdf tutti gli orari validi, uno per pagina
|
||||||
|
// /calendario/settembre-1-18.pdf un orario solo
|
||||||
|
//
|
||||||
|
// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file
|
||||||
|
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica
|
||||||
|
// coi clienti.
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
// "tutti" segue la stessa regola della pagina: gli orari scaduti non ci sono. Un PDF che si
|
||||||
|
// porta dietro il periodo finito farebbe cercare lezioni che non esistono più.
|
||||||
|
const cals = params.id === 'tutti'
|
||||||
|
? calendariDaMostrare(new Date()).map((m) => m.cal)
|
||||||
|
: calendari.filter((c) => c.id === params.id);
|
||||||
|
|
||||||
|
// Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa
|
||||||
|
// credere che il calendario sia vuoto.
|
||||||
|
if (cals.length === 0) return new Response('Calendario inesistente.', { status: 404 });
|
||||||
|
|
||||||
|
// Le giornate fuori griglia stanno anche nel PDF: è il file che gira su WhatsApp, e chi lo
|
||||||
|
// riceve non ha davanti la pagina dove sono scritte.
|
||||||
|
const pdf = await generaCalendariPdf(cals, avvisiAttivi(new Date()));
|
||||||
|
return new Response(new Uint8Array(pdf), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
|
||||||
|
// scheda e su telefono resterebbe lì, senza finire fra i file.
|
||||||
|
'Content-Disposition': `attachment; filename="${nomeFilePdf(cals)}"`,
|
||||||
|
'Content-Length': String(pdf.length),
|
||||||
|
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
|
||||||
|
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../layouts/Base.astro';
|
|
||||||
import { getDb } from '../lib/db';
|
|
||||||
import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
|
||||||
import { rateLimit } from '../lib/rate-limit';
|
|
||||||
import { safeNext } from '../lib/safe-next';
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const dest = safeNext(Astro.url.searchParams.get('next'));
|
|
||||||
|
|
||||||
// Già loggato → via dal login.
|
|
||||||
const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
|
|
||||||
if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
|
|
||||||
|
|
||||||
let error = '';
|
|
||||||
if (Astro.request.method === 'POST') {
|
|
||||||
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
|
|
||||||
error = 'Troppi tentativi: riprova tra 15 minuti.';
|
|
||||||
} else {
|
|
||||||
const form = await Astro.request.formData();
|
|
||||||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
|
||||||
if (token) {
|
|
||||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
|
||||||
httpOnly: true, sameSite: 'lax', path: '/',
|
|
||||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
|
||||||
});
|
|
||||||
return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
|
|
||||||
}
|
|
||||||
error = 'Credenziali non valide.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
---
|
|
||||||
<Base title="Accedi" description="Area riservata InsanityLab">
|
|
||||||
<section class="section">
|
|
||||||
<div class="container login-wrap">
|
|
||||||
<h1 class="section-heading">Accedi</h1>
|
|
||||||
{error && <p class="login-err">{error}</p>}
|
|
||||||
<form method="post" class="login-form">
|
|
||||||
<input type="hidden" name="next" value={dest} />
|
|
||||||
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
|
|
||||||
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
|
||||||
<button class="btn btn--dark" type="submit">Entra</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Base>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.login-wrap { max-width: 380px; margin: 0 auto; }
|
|
||||||
.login-form { display: grid; gap: 14px; margin-top: 20px; }
|
|
||||||
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
|
|
||||||
.login-err { color: #a33; }
|
|
||||||
</style>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../layouts/Base.astro';
|
|
||||||
import T from '../components/content/T.astro';
|
|
||||||
import { t, resolveContent } from '../lib/content';
|
|
||||||
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
|
|
||||||
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account,
|
|
||||||
// inserisce i dati e paga via Stripe dentro la piattaforma. Link centralizzato qui:
|
|
||||||
// quando avremo gli ID membership per singolo prodotto basterà mappare BSPORT_URL card per card.
|
|
||||||
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
// I testi sono editabili dal pannello contenuti (tag `promo.*`). Qui resta solo la
|
|
||||||
// struttura: quante card, quante feature ciascuna e quale è "in evidenza".
|
|
||||||
const cards = [
|
|
||||||
{ id: 1, features: 4, featured: false },
|
|
||||||
{ id: 2, features: 4, featured: true },
|
|
||||||
{ id: 3, features: 3, featured: false },
|
|
||||||
{ id: 4, features: 3, featured: false },
|
|
||||||
];
|
|
||||||
const faqs = [1, 2, 3, 4, 5, 6];
|
|
||||||
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
|
|
||||||
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
|
|
||||||
const wa = t('global.whatsapp');
|
|
||||||
---
|
|
||||||
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
|
|
||||||
<div class="lp">
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<section class="hero">
|
|
||||||
<T tag="promo.hero.badge" as="div" class="badge" />
|
|
||||||
<T tag="promo.hero.title1" as="h1" />
|
|
||||||
<T tag="promo.hero.accent" as="p" class="accent" />
|
|
||||||
<T tag="promo.hero.title2" as="h1" />
|
|
||||||
<T tag="promo.hero.desc" as="p" class="desc" />
|
|
||||||
<a class="btn-sand" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="launch">
|
|
||||||
<T tag="promo.launch.badge" as="div" class="badge" />
|
|
||||||
<T tag="promo.launch.title" as="h1" />
|
|
||||||
<T tag="promo.launch.accent" as="p" class="accent" />
|
|
||||||
<T tag="promo.launch.desc" as="p" class="desc" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="why5">
|
|
||||||
<T tag="promo.why5.text" as="p" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="cards-wrap" id="proposte">
|
|
||||||
<div class="cards">
|
|
||||||
{cards.map((c) => (
|
|
||||||
<div class:list={['card', c.featured && 'featured']}>
|
|
||||||
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
|
|
||||||
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
|
|
||||||
<T tag={`promo.card.${c.id}.title`} as="div" class="title" />
|
|
||||||
<div class="price-row">
|
|
||||||
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
|
|
||||||
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
|
|
||||||
</div>
|
|
||||||
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
|
|
||||||
<ul>
|
|
||||||
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
|
|
||||||
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<T tag="promo.validity-note" as="p" class="validity-note" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="faq-wrap">
|
|
||||||
<T tag="promo.faq.title" as="h1" />
|
|
||||||
{faqs.map((n) => {
|
|
||||||
const a = resolveContent(`promo.faq.${n}.a`);
|
|
||||||
return (
|
|
||||||
<div class="faq-block">
|
|
||||||
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
|
|
||||||
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Base>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
|
|
||||||
document.querySelectorAll('.lp .faq-block .faq-q').forEach((q) => {
|
|
||||||
const block = q.closest('.faq-block');
|
|
||||||
const toggle = q.querySelector('.faq-toggle');
|
|
||||||
q.setAttribute('role', 'button');
|
|
||||||
q.setAttribute('tabindex', '0');
|
|
||||||
q.setAttribute('aria-expanded', 'false');
|
|
||||||
const set = (open: boolean) => {
|
|
||||||
block?.classList.toggle('is-open', open);
|
|
||||||
q.setAttribute('aria-expanded', String(open));
|
|
||||||
if (toggle) toggle.textContent = open ? '−' : '+';
|
|
||||||
};
|
|
||||||
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
|
|
||||||
q.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
|
|
||||||
scope della pagina): con lo scope Astro il CSS non li raggiungerebbe. Ogni regola è
|
|
||||||
prefissata `.lp`, quindi resta confinata a questa landing senza toccare il resto del sito. -->
|
|
||||||
<style is:global>
|
|
||||||
.lp { background: #ECE7DC; }
|
|
||||||
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
|
|
||||||
|
|
||||||
/* HERO */
|
|
||||||
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
|
|
||||||
.lp .badge {
|
|
||||||
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
|
|
||||||
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
|
|
||||||
color: #C4A882; font-weight: 500; text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
|
|
||||||
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
|
|
||||||
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
|
|
||||||
.lp .btn-sand {
|
|
||||||
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
|
|
||||||
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
/* La freccia è decorazione (il testo del tag non la include più) */
|
|
||||||
.lp .btn-sand::after { content: ' →'; }
|
|
||||||
.lp .card .buy::after { content: ' →'; }
|
|
||||||
|
|
||||||
/* LAUNCH */
|
|
||||||
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
|
|
||||||
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
|
|
||||||
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
|
|
||||||
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
|
|
||||||
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
|
|
||||||
|
|
||||||
/* PERCHE 5 */
|
|
||||||
.lp .why5 { padding: 48px 64px 8px; }
|
|
||||||
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
|
|
||||||
|
|
||||||
/* CARDS */
|
|
||||||
.lp .cards-wrap { padding: 32px 64px 72px; }
|
|
||||||
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
|
||||||
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
|
|
||||||
.lp .card.featured { border: 2px solid #2D2D2D; }
|
|
||||||
.lp .featured-badge {
|
|
||||||
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
|
|
||||||
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
|
|
||||||
}
|
|
||||||
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
|
|
||||||
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
|
|
||||||
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
|
|
||||||
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
|
|
||||||
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
|
|
||||||
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
|
|
||||||
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
|
|
||||||
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
|
|
||||||
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
|
|
||||||
.lp .card .buy {
|
|
||||||
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
|
|
||||||
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
|
|
||||||
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
|
|
||||||
}
|
|
||||||
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
|
|
||||||
|
|
||||||
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
|
|
||||||
|
|
||||||
/* FAQ */
|
|
||||||
.lp .faq-wrap { padding: 72px 64px; }
|
|
||||||
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
|
|
||||||
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
|
|
||||||
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
|
|
||||||
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
|
|
||||||
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
|
|
||||||
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
|
|
||||||
.lp .faq-toggle {
|
|
||||||
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
|
|
||||||
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
|
|
||||||
transition: background .2s ease, color .2s ease, border-color .2s ease;
|
|
||||||
}
|
|
||||||
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
|
|
||||||
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
|
|
||||||
.lp .faq-block.is-open .faq-a { display: block; }
|
|
||||||
.lp .faq-a .hl { color: #8A6E3F; }
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
|
|
||||||
.lp .cards { grid-template-columns: 1fr; }
|
|
||||||
.lp .hero h1 { font-size: 32px; }
|
|
||||||
.lp .launch h1 { font-size: 28px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// La landing dell'inaugurazione del 19 settembre. Il blocco del titolo è il ritaglio
|
||||||
|
// della locandina vera (font e calligrafia originali, non riprodotti): il resto della
|
||||||
|
// pagina usa la tipografia del sito e i colori campionati dalla locandina stessa.
|
||||||
|
// L'URL di questa pagina è quello stampato nel QR code, quindi non va cambiato.
|
||||||
|
const WHATSAPP = '351 753 5977';
|
||||||
|
const waLink = `https://wa.me/39${WHATSAPP.replace(/\D/g, '')}`;
|
||||||
|
|
||||||
|
type Slot = { ora: string; nome?: string };
|
||||||
|
type Gruppo = { label: string; slots: Slot[] };
|
||||||
|
|
||||||
|
const mattino: Gruppo[] = [
|
||||||
|
{ label: "Corsi Olistici — 20' a slot", slots: [
|
||||||
|
{ ora: '10:00–10:20', nome: 'Mobility' },
|
||||||
|
{ ora: '10:30–10:50', nome: 'Yoga' },
|
||||||
|
{ ora: '11:00–11:20', nome: 'Pilates Mat' },
|
||||||
|
{ ora: '11:30–11:50', nome: 'Reformer' },
|
||||||
|
{ ora: '12:00–12:20', nome: 'Reformer' },
|
||||||
|
{ ora: '12:30–12:50', nome: 'Reformer' },
|
||||||
|
] },
|
||||||
|
{ label: "Personal Training — 30' a slot", slots: ['10:30', '11:00', '11:30', '12:00', '12:30'].map((ora) => ({ ora })) },
|
||||||
|
{ label: "Check-up e Consulenze — 30' a slot", slots: ['10:30', '11:00', '11:30', '12:00', '12:30'].map((ora) => ({ ora })) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pomeriggio: Gruppo[] = [
|
||||||
|
{ label: "Corsi Olistici — 20' a slot", slots: [
|
||||||
|
{ ora: '16:00–16:20', nome: 'Reformer' },
|
||||||
|
{ ora: '16:30–16:50', nome: 'Reformer' },
|
||||||
|
{ ora: '17:00–17:20', nome: 'Pilates' },
|
||||||
|
{ ora: '17:30–17:50', nome: 'Mobility' },
|
||||||
|
] },
|
||||||
|
{ label: "Personal Training — 30' a slot", slots: ['16:00', '16:30', '17:00', '17:30'].map((ora) => ({ ora })) },
|
||||||
|
{ label: "Check-up e Consulenze — 30' a slot", slots: ['16:00', '16:30', '17:00', '17:30'].map((ora) => ({ ora })) },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<Base
|
||||||
|
title="In-Sanity Hour — 19 settembre 2026"
|
||||||
|
description="L'inaugurazione di InsanityLab: sabato 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti."
|
||||||
|
>
|
||||||
|
<section class="ev-hero">
|
||||||
|
<div class="ev-hero__inner" data-reveal>
|
||||||
|
<img
|
||||||
|
class="ev-hero__titolo"
|
||||||
|
src="/img/insanity-hour-titolo.png"
|
||||||
|
width="580" height="251"
|
||||||
|
alt="Save the date — In-Sanity Hour"
|
||||||
|
/>
|
||||||
|
<p class="ev-hero__giorno">19</p>
|
||||||
|
<p class="ev-hero__mese">Settembre</p>
|
||||||
|
<p class="ev-hero__inaug">Inaugurazione</p>
|
||||||
|
<p class="ev-hero__claim">Un giorno solo per vivere il Lab da vicino.</p>
|
||||||
|
<p class="ev-hero__gusto">DJ set · Aperitivo</p>
|
||||||
|
<p class="ev-hero__luogo">Via Pietro di Mattiolo, 5 — Bologna</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section ev-intro">
|
||||||
|
<div class="container" data-reveal>
|
||||||
|
<p class="ev-intro__testo">
|
||||||
|
Una giornata aperta a tutti — clienti e non — per scoprire la nuova struttura e conoscere
|
||||||
|
il nostro staff. Vivi l'esperienza In-Sanity Lab da vicino: mini class, allenamenti,
|
||||||
|
consulenze e check up avanzato.
|
||||||
|
</p>
|
||||||
|
<p class="ev-intro__gratis">Solo per oggi, tutto gratuito</p>
|
||||||
|
<p class="ev-intro__testo">
|
||||||
|
L'ingresso e l'aperitivo serale sono liberi, porta chi vuoi. La prenotazione serve solo
|
||||||
|
per assicurarti il posto nei singoli slot di prova.
|
||||||
|
</p>
|
||||||
|
<a class="btn" href={waLink} target="_blank" rel="noopener">Prenotati su WhatsApp</a>
|
||||||
|
<p class="ev-nota">{WHATSAPP} — rispondiamo entro la giornata</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section ev-programma">
|
||||||
|
<div class="container">
|
||||||
|
<p class="eyebrow" data-reveal>Programma della giornata</p>
|
||||||
|
<h2 data-reveal>Come si svolge<br />l'In-Sanity Hour</h2>
|
||||||
|
|
||||||
|
<article class="ev-blocco" data-reveal>
|
||||||
|
<p class="ev-blocco__ora">10:00</p>
|
||||||
|
<h3>Apertura</h3>
|
||||||
|
<p class="ev-blocco__desc">
|
||||||
|
Apertura ufficiale del Lab. Visita libera degli spazi, promozioni dedicate attive
|
||||||
|
per tutta la giornata.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ev-blocco" data-reveal>
|
||||||
|
<p class="ev-blocco__ora">10:00 – 13:00</p>
|
||||||
|
<h3>Prova i nostri servizi</h3>
|
||||||
|
{mattino.map((gruppo) => (
|
||||||
|
<div class="ev-gruppo">
|
||||||
|
<p class="ev-gruppo__label">{gruppo.label}</p>
|
||||||
|
<ul class="ev-slots">
|
||||||
|
{gruppo.slots.map((slot) => (
|
||||||
|
<li class:list={['ev-slot', { 'ev-slot--corso': slot.nome }]}>
|
||||||
|
<span class="ev-slot__ora">{slot.ora}</span>
|
||||||
|
{slot.nome && <span class="ev-slot__nome">{slot.nome}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<a class="btn btn--chiaro" href={waLink} target="_blank" rel="noopener">Prenota uno slot</a>
|
||||||
|
<p class="ev-nota">{WHATSAPP} — scrivici per riservare uno slot del mattino</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ev-blocco ev-blocco--pausa" data-reveal>
|
||||||
|
<p class="ev-blocco__ora">13:00 – 16:00</p>
|
||||||
|
<h3>Pausa</h3>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ev-blocco" data-reveal>
|
||||||
|
<p class="ev-blocco__ora">16:00 – 18:00</p>
|
||||||
|
<h3>Prova i nostri servizi</h3>
|
||||||
|
{pomeriggio.map((gruppo) => (
|
||||||
|
<div class="ev-gruppo">
|
||||||
|
<p class="ev-gruppo__label">{gruppo.label}</p>
|
||||||
|
<ul class="ev-slots">
|
||||||
|
{gruppo.slots.map((slot) => (
|
||||||
|
<li class:list={['ev-slot', { 'ev-slot--corso': slot.nome }]}>
|
||||||
|
<span class="ev-slot__ora">{slot.ora}</span>
|
||||||
|
{slot.nome && <span class="ev-slot__nome">{slot.nome}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<a class="btn btn--chiaro" href={waLink} target="_blank" rel="noopener">Prenota uno slot</a>
|
||||||
|
<p class="ev-nota">{WHATSAPP} — scrivici per riservare uno slot del pomeriggio</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ev-blocco ev-blocco--festa" data-reveal>
|
||||||
|
<p class="ev-blocco__ora">18:00 – 22:00</p>
|
||||||
|
<h3>Si festeggia!</h3>
|
||||||
|
<p class="ev-blocco__desc">
|
||||||
|
DJ set e aperitivo per tutti. Brindisi e la nuova sede finalmente aperta a tutti.
|
||||||
|
Tra un drink e l'altro, la serata avrà anche i suoi momenti a sorpresa.
|
||||||
|
Promozioni esclusive valide solo durante l'evento.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ev-chiusura">
|
||||||
|
<div class="container" data-reveal>
|
||||||
|
<blockquote class="ev-citazione">
|
||||||
|
«Qui non ti alleni per somigliare a nessuno,<br />ti muovi per ricordare chi sei.»
|
||||||
|
</blockquote>
|
||||||
|
<p class="ev-chiusura__luogo">Via Pietro di Mattiolo, 5 — 40139, Bologna</p>
|
||||||
|
<a class="ev-locandina" href="/locandina-insanity-hour.pdf" download>Scarica la locandina</a>
|
||||||
|
<p class="ev-torna"><a href="/promo-eventi">← Torna a promo ed eventi</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Colori campionati dalla locandina: il marrone e il beige sono quelli veri, non quelli
|
||||||
|
del sito, così la pagina e il volantino stampato si riconoscono come la stessa cosa. */
|
||||||
|
.ev-hero, .ev-intro, .ev-programma, .ev-chiusura {
|
||||||
|
--ev-brown: #4f3126;
|
||||||
|
--ev-brown-deep: #3d251c;
|
||||||
|
--ev-beige: #d0b49e;
|
||||||
|
--ev-cream: #f6f1e9;
|
||||||
|
--ev-line: rgba(208, 180, 158, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hero: il ritaglio della locandina su fondo marrone identico, così l'immagine
|
||||||
|
non ha bordi visibili e sembra composta con il resto della pagina. --- */
|
||||||
|
.ev-hero {
|
||||||
|
background: radial-gradient(circle at 50% -10%, #5c3a2d 0%, var(--ev-brown) 55%, var(--ev-brown-deep) 100%);
|
||||||
|
color: var(--ev-cream);
|
||||||
|
padding: 64px 24px 72px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ev-hero::before {
|
||||||
|
content: ''; position: absolute; inset: 14px;
|
||||||
|
border: 1px solid var(--ev-line); pointer-events: none;
|
||||||
|
}
|
||||||
|
.ev-hero__inner { max-width: 640px; margin: 0 auto; position: relative; }
|
||||||
|
.ev-hero__titolo { width: min(100%, 540px); margin: 0 auto; mix-blend-mode: lighten; }
|
||||||
|
.ev-hero__giorno {
|
||||||
|
font-family: var(--font-heading); font-size: clamp(3.4rem, 11vw, 4.6rem);
|
||||||
|
font-weight: 300; line-height: 1; letter-spacing: .04em; margin: 26px 0 0;
|
||||||
|
}
|
||||||
|
.ev-hero__mese {
|
||||||
|
font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
|
||||||
|
letter-spacing: .35em; text-transform: uppercase; color: var(--ev-beige); margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.ev-hero__inaug {
|
||||||
|
display: inline-block; margin: 20px 0 0; padding: 7px 16px;
|
||||||
|
border: 1px solid var(--ev-line);
|
||||||
|
font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
||||||
|
letter-spacing: .24em; text-transform: uppercase; color: var(--ev-cream);
|
||||||
|
}
|
||||||
|
.ev-hero__claim { margin: 26px 0 0; font-size: 1.05rem; line-height: 1.7; color: rgba(246, 241, 233, .9); }
|
||||||
|
.ev-hero__gusto {
|
||||||
|
margin: 18px 0 0; font-family: var(--font-heading); font-size: .7rem; font-weight: 600;
|
||||||
|
letter-spacing: .28em; text-transform: uppercase; color: var(--ev-beige);
|
||||||
|
}
|
||||||
|
.ev-hero__luogo {
|
||||||
|
margin: 26px 0 0; font-family: var(--font-heading); font-size: .68rem;
|
||||||
|
letter-spacing: .18em; text-transform: uppercase; color: rgba(246, 241, 233, .6);
|
||||||
|
}
|
||||||
|
.ev-locandina {
|
||||||
|
display: inline-block; margin-top: 34px; padding: 12px 22px;
|
||||||
|
border: 1px solid var(--ev-beige); color: var(--ev-beige); text-decoration: none;
|
||||||
|
font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
||||||
|
letter-spacing: .2em; text-transform: uppercase; transition: background .2s, color .2s;
|
||||||
|
}
|
||||||
|
.ev-locandina:hover { background: var(--ev-beige); color: var(--ev-brown); }
|
||||||
|
|
||||||
|
/* --- Intro --- */
|
||||||
|
.ev-intro .container { max-width: 720px; text-align: center; }
|
||||||
|
.ev-intro__testo { margin: 0 0 18px; }
|
||||||
|
.ev-intro__gratis {
|
||||||
|
font-family: var(--font-heading); font-size: clamp(1.15rem, 2.6vw, 1.5rem);
|
||||||
|
font-weight: 600; letter-spacing: .06em; color: var(--ev-brown); margin: 26px 0;
|
||||||
|
}
|
||||||
|
.ev-nota {
|
||||||
|
margin: 14px 0 0; font-size: .84rem; color: var(--c-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Programma --- */
|
||||||
|
.ev-programma { background: var(--c-bg-alt); }
|
||||||
|
.ev-programma .container { max-width: 800px; }
|
||||||
|
.ev-programma .eyebrow, .ev-programma h2 { text-align: center; }
|
||||||
|
.ev-programma h2 { margin-bottom: 46px; }
|
||||||
|
.ev-blocco {
|
||||||
|
background: #fff; border-left: 3px solid var(--ev-beige);
|
||||||
|
padding: 30px 30px 34px; margin-bottom: 20px; text-align: center;
|
||||||
|
}
|
||||||
|
.ev-blocco__ora {
|
||||||
|
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
|
||||||
|
letter-spacing: .2em; color: var(--ev-brown); margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.ev-blocco h3 { text-transform: uppercase; letter-spacing: .08em; margin: 0 0 12px; }
|
||||||
|
.ev-blocco__desc { margin: 0; }
|
||||||
|
.ev-blocco--pausa { border-left-color: var(--c-text-light); opacity: .75; }
|
||||||
|
.ev-blocco--festa { border-left-color: var(--ev-brown); }
|
||||||
|
|
||||||
|
.ev-gruppo { margin-bottom: 24px; }
|
||||||
|
.ev-gruppo__label {
|
||||||
|
font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
|
||||||
|
letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.ev-slots {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
|
||||||
|
}
|
||||||
|
.ev-slot {
|
||||||
|
border: 1px solid var(--ev-line); padding: 8px 14px;
|
||||||
|
font-family: var(--font-heading); font-size: .78rem; color: var(--ev-brown);
|
||||||
|
}
|
||||||
|
.ev-slot--corso { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.ev-slot__nome { font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-text-light); }
|
||||||
|
.btn--chiaro { margin-top: 10px; }
|
||||||
|
|
||||||
|
/* --- Chiusura --- */
|
||||||
|
.ev-chiusura {
|
||||||
|
background: var(--ev-brown); color: var(--ev-cream);
|
||||||
|
padding: 64px 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.ev-citazione {
|
||||||
|
margin: 0; font-size: clamp(1.35rem, 3.4vw, 1.9rem); line-height: 1.5;
|
||||||
|
font-style: italic; color: var(--ev-cream);
|
||||||
|
}
|
||||||
|
.ev-chiusura__luogo {
|
||||||
|
margin: 30px 0 0; font-family: var(--font-heading); font-size: .68rem;
|
||||||
|
letter-spacing: .18em; text-transform: uppercase; color: var(--ev-beige);
|
||||||
|
}
|
||||||
|
.ev-torna { margin: 34px 0 0; }
|
||||||
|
.ev-torna a {
|
||||||
|
font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em;
|
||||||
|
text-transform: uppercase; color: rgba(246, 241, 233, .7); text-decoration: none;
|
||||||
|
}
|
||||||
|
.ev-torna a:hover { color: var(--ev-beige); }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.ev-hero { padding: 44px 18px 54px; }
|
||||||
|
.ev-blocco { padding: 24px 18px 28px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import PageHero from '../../components/PageHero.astro';
|
||||||
|
import T from '../../components/content/T.astro';
|
||||||
|
import { t } from '../../lib/content';
|
||||||
|
import { edizioneAttiva } from '../../data/promo-edizioni';
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// Chi entra da «Promo ed eventi» sceglie: le promozioni in corso oppure gli eventi del
|
||||||
|
// centro (chiesto dal cliente il 17/08/2026). Erano la stessa pagina, e chi cercava
|
||||||
|
// l'inaugurazione finiva in mezzo ai prezzi dei pacchetti.
|
||||||
|
//
|
||||||
|
// Il bottone delle promo dice se ce n'è davvero una in corso: mandare su una pagina che
|
||||||
|
// risponde «nessuna promozione» senza averlo detto prima è un giro a vuoto.
|
||||||
|
const ed = edizioneAttiva(new Date());
|
||||||
|
// La pagina cambia contenuto col passare del tempo (l'edizione promo scade da sola): una
|
||||||
|
// copia in cache la congelerebbe a com'era.
|
||||||
|
Astro.response.headers.set('Cache-Control', 'no-store');
|
||||||
|
---
|
||||||
|
<Base title={t('promo-eventi.meta.title')} description={t('promo-eventi.meta.description')}>
|
||||||
|
<PageHero title={t('promo-eventi.hero.title')} />
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<T tag="promo-eventi.intro" as="p" />
|
||||||
|
</div>
|
||||||
|
<div class="scelta">
|
||||||
|
<a class="scelta__card" href="/promo-eventi/promo">
|
||||||
|
<T tag="promo-eventi.promo.label" as="h2" />
|
||||||
|
<T tag="promo-eventi.promo.desc" as="p" />
|
||||||
|
{!ed && <T tag="promo-eventi.promo.nessuna" as="p" class="scelta__nota" />}
|
||||||
|
<span class="btn">{t('promo-eventi.promo.cta')}</span>
|
||||||
|
</a>
|
||||||
|
<a class="scelta__card scelta__card--dark" href="/promo-eventi/eventi">
|
||||||
|
<T tag="promo-eventi.eventi.label" as="h2" />
|
||||||
|
<T tag="promo-eventi.eventi.desc" as="p" />
|
||||||
|
<span class="btn btn--light">{t('promo-eventi.eventi.cta')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scelta { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||||
|
.scelta__card { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; padding: 48px 40px; background: #fff; box-shadow: 0 6px 24px rgba(0,0,0,.08); text-decoration: none; transition: transform .3s ease, box-shadow .3s ease; }
|
||||||
|
.scelta__card:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(0,0,0,.12); }
|
||||||
|
.scelta__card :global(h2) { text-transform: uppercase; letter-spacing: .12em; font-size: 1.4rem; margin: 0; }
|
||||||
|
.scelta__card :global(p) { font-size: .92rem; margin: 0; }
|
||||||
|
.scelta__card .btn { margin-top: auto; }
|
||||||
|
.scelta__card--dark { background: var(--c-dark); }
|
||||||
|
.scelta__card--dark :global(h2), .scelta__card--dark :global(p) { color: #efe9e3; }
|
||||||
|
.scelta__nota { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); }
|
||||||
|
@media (max-width: 767px) { .scelta { grid-template-columns: 1fr; } .scelta__card { padding: 34px 26px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import T from '../../components/content/T.astro';
|
||||||
|
import { t, resolveContent } from '../../lib/content';
|
||||||
|
import { edizioneAttiva, tagEdizione } from '../../data/promo-edizioni';
|
||||||
|
import { getSessionUser, SESSION_COOKIE } from '../../lib/auth';
|
||||||
|
import { getDb } from '../../lib/db';
|
||||||
|
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
|
||||||
|
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account e sceglie lì
|
||||||
|
// dentro il prodotto, poi paga via Stripe. Il link è uno solo per tutte le card, ed è voluto:
|
||||||
|
// il catalogo lo presenta bsport dopo l'accesso.
|
||||||
|
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// Quale promozione è in vetrina lo decide il calendario, non un deploy: l'edizione attiva è
|
||||||
|
// quella la cui finestra contiene questo istante (vedi `data/promo-edizioni.ts`). Alla
|
||||||
|
// scadenza dell'una subentra l'altra da sola, e quando non ce n'è nessuna la pagina lo dice
|
||||||
|
// invece di lasciare online i prezzi di un'offerta finita.
|
||||||
|
//
|
||||||
|
// Anteprima: `?anteprima=2026-08-20` mostra l'edizione di quella data. Solo per chi è loggato
|
||||||
|
// — un visitatore non deve poter vedere prezzi non ancora validi, o non più.
|
||||||
|
const param = Astro.url.searchParams.get('anteprima');
|
||||||
|
const token = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
const utente = token ? getSessionUser(getDb(), token) : null;
|
||||||
|
// Una data secca (`2026-08-20`) sarebbe letta come UTC: la si porta a mezzogiorno italiano,
|
||||||
|
// lontano dai confini di mezzanotte dove sta la differenza fra un'edizione e l'altra.
|
||||||
|
const quandoParam = param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? `${param}T12:00:00+02:00` : param;
|
||||||
|
const dataLeggibile = Boolean(quandoParam && !Number.isNaN(Date.parse(quandoParam)));
|
||||||
|
const anteprima = Boolean(utente && dataLeggibile);
|
||||||
|
// Un'anteprima chiesta e non applicata va **detta**: altrimenti la pagina risponde con
|
||||||
|
// l'edizione di oggi e due date diverse danno lo stesso risultato — che si legge come «la
|
||||||
|
// funzione non funziona» invece che «non sei entrato».
|
||||||
|
const anteprimaIgnorata = !param || anteprima ? null : (!utente ? 'login' : 'data');
|
||||||
|
const quando = anteprima ? new Date(quandoParam!) : new Date();
|
||||||
|
const ed = edizioneAttiva(quando);
|
||||||
|
// La pagina cambia contenuto col passare del tempo: una copia in cache la congelerebbe alla
|
||||||
|
// vecchia edizione, e lo scambio avverrebbe quando pare al proxy invece che a mezzanotte.
|
||||||
|
Astro.response.headers.set('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
// Tag dell'edizione attiva: `tg('card.1.price')` → `promo.card.1.price` o `bundle.card.1.price`.
|
||||||
|
const tg = (suffisso: string) => (ed ? tagEdizione(ed, suffisso) : suffisso);
|
||||||
|
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
|
||||||
|
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
|
||||||
|
const wa = t('global.whatsapp');
|
||||||
|
const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`;
|
||||||
|
// Scadenza dell'edizione in corso (tag editabile dal pannello, ISO con offset). Viene solo
|
||||||
|
// passata al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto
|
||||||
|
// col tempo del visitatore.
|
||||||
|
const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
|
||||||
|
---
|
||||||
|
<Base title="Promo ed eventi — InsanityLab" description={ed ? t(tg('hero.desc')).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : 'Le promozioni di apertura sono terminate: scrivici su WhatsApp per i percorsi attivi.'}>
|
||||||
|
<div class="lp">
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{anteprima && (
|
||||||
|
<div class="anteprima">
|
||||||
|
Anteprima al {quando.toLocaleString('it-IT', { timeZone: 'Europe/Rome' })} —
|
||||||
|
{ed ? ` edizione «${ed.id}»` : ' nessuna edizione attiva'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{anteprimaIgnorata === 'login' && (
|
||||||
|
<div class="anteprima ignorata">
|
||||||
|
Anteprima non applicata: questa è la pagina di oggi. Entra da <a href="/admin/login">/admin/login</a> e ricarica.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{anteprimaIgnorata === 'data' && (
|
||||||
|
<div class="anteprima ignorata">
|
||||||
|
Anteprima non applicata: «{param}» non è una data leggibile. Usa il formato 2026-08-25.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ed ? (
|
||||||
|
<Fragment>
|
||||||
|
<section class="hero">
|
||||||
|
<T tag={tg('hero.badge')} as="div" class="badge" />
|
||||||
|
<T tag={tg('hero.title1')} as="h1" />
|
||||||
|
<T tag={tg('hero.accent')} as="p" class="accent" />
|
||||||
|
<T tag={tg('hero.title2')} as="h1" />
|
||||||
|
<T tag={tg('hero.desc')} as="p" class="desc" />
|
||||||
|
<a class="btn-sand" href="#proposte"><T tag={tg('hero.cta')} as="span" /></a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{ed.launch && (
|
||||||
|
<Fragment>
|
||||||
|
<section class="launch">
|
||||||
|
<T tag={tg('launch.badge')} as="div" class="badge" />
|
||||||
|
<T tag={tg('launch.title')} as="h1" />
|
||||||
|
<T tag={tg('launch.accent')} as="p" class="accent" />
|
||||||
|
<T tag={tg('launch.desc')} as="p" class="desc" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="why5">
|
||||||
|
<T tag={tg('why5.text')} as="p" />
|
||||||
|
</section>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section class="cards-wrap" id="proposte">
|
||||||
|
<div class="countdown" data-deadline={countdownDeadline} hidden>
|
||||||
|
<T tag={tg('countdown.label')} as="div" class="cd-label" />
|
||||||
|
<div class="cd-boxes">
|
||||||
|
<div class="cd-box"><span class="cd-num" data-cd="days">–</span><T tag={tg('countdown.unit.days')} as="span" class="cd-unit" /></div>
|
||||||
|
<div class="cd-box"><span class="cd-num" data-cd="hours">–</span><T tag={tg('countdown.unit.hours')} as="span" class="cd-unit" /></div>
|
||||||
|
<div class="cd-box"><span class="cd-num" data-cd="minutes">–</span><T tag={tg('countdown.unit.minutes')} as="span" class="cd-unit" /></div>
|
||||||
|
<div class="cd-box"><span class="cd-num" data-cd="seconds">–</span><T tag={tg('countdown.unit.seconds')} as="span" class="cd-unit" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class:list={['cards', `cards-${ed.cards.length}`]}>
|
||||||
|
{ed.cards.map((c) => (
|
||||||
|
<div class:list={['card', c.featured && 'featured']}>
|
||||||
|
{c.featured && <T tag={tg(`card.${c.id}.badge`)} as="div" class="featured-badge" />}
|
||||||
|
<T tag={tg(`card.${c.id}.eyebrow`)} as="div" class="eyebrow" />
|
||||||
|
<T tag={tg(`card.${c.id}.title`)} as="div" class="title" />
|
||||||
|
{c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />}
|
||||||
|
<div class="price-row">
|
||||||
|
<T tag={tg(`card.${c.id}.price`)} as="div" class="price" />
|
||||||
|
{c.priceOld && <T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" />}
|
||||||
|
</div>
|
||||||
|
{c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />}
|
||||||
|
<ul>
|
||||||
|
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
|
||||||
|
<T tag={tg(`card.${c.id}.feature.${m}`)} as="li" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{c.desc && <T tag={tg(`card.${c.id}.desc`)} as="p" class="card-desc" />}
|
||||||
|
{c.note && (
|
||||||
|
<div class="note-box">
|
||||||
|
<T tag={tg(`card.${c.id}.note-label`)} as="div" class="note-label" />
|
||||||
|
<T tag={tg(`card.${c.id}.note`)} as="div" class="note-text" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag={tg('buy-label')} as="span" /></a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<T tag={tg('validity-note')} as="p" class="validity-note" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="faq-wrap">
|
||||||
|
<T tag={tg('faq.title')} as="h1" />
|
||||||
|
{ed.faq.map((n) => {
|
||||||
|
const a = resolveContent(tg(`faq.${n}.a`));
|
||||||
|
return (
|
||||||
|
<div class="faq-block">
|
||||||
|
<div class="faq-q"><T tag={tg(`faq.${n}.q`)} as="p" /><div class="faq-toggle">+</div></div>
|
||||||
|
<p class:list={['faq-a', a.classes]} data-tag={tg(`faq.${n}.a`)} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<section class="hero chiusa">
|
||||||
|
<T tag="promo.chiusa.title" as="h1" />
|
||||||
|
<T tag="promo.chiusa.desc" as="p" class="desc" />
|
||||||
|
<a class="btn-sand" href={waLink} target="_blank" rel="noopener"><T tag="promo.chiusa.cta" as="span" /></a>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Countdown alla scadenza della promo. Parte nascosto (attributo `hidden` nel markup) e viene
|
||||||
|
// mostrato solo se la deadline è una data valida ancora futura: così, a promo finita, il blocco
|
||||||
|
// semplicemente non compare — nessun messaggio, nessun salto di layout.
|
||||||
|
const cd = document.querySelector<HTMLElement>('.lp .countdown');
|
||||||
|
if (cd) {
|
||||||
|
const end = Date.parse(cd.dataset.deadline || '');
|
||||||
|
const nums = new Map<string, HTMLElement>();
|
||||||
|
cd.querySelectorAll<HTMLElement>('.cd-num').forEach((n) => nums.set(n.dataset.cd!, n));
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// Dichiarato prima di `tick` perché la prima chiamata è sincrona: se la promo è già scaduta
|
||||||
|
// il timer va fermato subito, e leggerlo dopo il `setInterval` darebbe un errore di TDZ.
|
||||||
|
let timer = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const left = end - Date.now();
|
||||||
|
if (Number.isNaN(end) || left <= 0) { cd.hidden = true; clearInterval(timer); return; }
|
||||||
|
const s = Math.floor(left / 1000);
|
||||||
|
nums.get('days')!.textContent = String(Math.floor(s / 86400));
|
||||||
|
nums.get('hours')!.textContent = pad(Math.floor(s / 3600) % 24);
|
||||||
|
nums.get('minutes')!.textContent = pad(Math.floor(s / 60) % 60);
|
||||||
|
nums.get('seconds')!.textContent = pad(s % 60);
|
||||||
|
cd.hidden = false;
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
timer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
|
||||||
|
document.querySelectorAll('.lp .faq-block .faq-q').forEach((q) => {
|
||||||
|
const block = q.closest('.faq-block');
|
||||||
|
const toggle = q.querySelector('.faq-toggle');
|
||||||
|
q.setAttribute('role', 'button');
|
||||||
|
q.setAttribute('tabindex', '0');
|
||||||
|
q.setAttribute('aria-expanded', 'false');
|
||||||
|
const set = (open: boolean) => {
|
||||||
|
block?.classList.toggle('is-open', open);
|
||||||
|
q.setAttribute('aria-expanded', String(open));
|
||||||
|
if (toggle) toggle.textContent = open ? '−' : '+';
|
||||||
|
};
|
||||||
|
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
|
||||||
|
q.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
|
||||||
|
scope della pagina): con lo scope Astro il CSS non li raggiungerebbe. Ogni regola è
|
||||||
|
prefissata `.lp`, quindi resta confinata a questa landing senza toccare il resto del sito. -->
|
||||||
|
<style is:global>
|
||||||
|
.lp { background: #ECE7DC; }
|
||||||
|
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
|
||||||
|
|
||||||
|
/* HERO */
|
||||||
|
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
|
||||||
|
.lp .badge {
|
||||||
|
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
|
||||||
|
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
|
||||||
|
color: #C4A882; font-weight: 500; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
|
||||||
|
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
|
||||||
|
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
|
||||||
|
.lp .btn-sand {
|
||||||
|
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
|
||||||
|
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
/* La freccia è decorazione (il testo del tag non la include più) */
|
||||||
|
.lp .btn-sand::after { content: ' →'; }
|
||||||
|
.lp .card .buy::after { content: ' →'; }
|
||||||
|
|
||||||
|
/* LAUNCH */
|
||||||
|
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
|
||||||
|
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
|
||||||
|
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
|
||||||
|
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
|
||||||
|
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
|
||||||
|
|
||||||
|
/* PERCHE 5 */
|
||||||
|
.lp .why5 { padding: 48px 64px 8px; }
|
||||||
|
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
|
||||||
|
|
||||||
|
/* COUNTDOWN */
|
||||||
|
/* `hidden` va forzato: i display flex qui sotto avrebbero comunque la meglio sul default UA. */
|
||||||
|
.lp .countdown[hidden] { display: none !important; }
|
||||||
|
.lp .countdown {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||||
|
background: #2D2D2D; border-radius: 14px; padding: 26px 24px; margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
.lp .countdown .cd-label {
|
||||||
|
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; color: #C4A882;
|
||||||
|
}
|
||||||
|
.lp .countdown .cd-boxes { display: flex; gap: 10px; }
|
||||||
|
.lp .countdown .cd-box {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 74px;
|
||||||
|
background: rgba(255,255,255,0.06); border: 0.5px solid rgba(196,168,130,0.35);
|
||||||
|
border-radius: 10px; padding: 12px 10px;
|
||||||
|
}
|
||||||
|
.lp .countdown .cd-num { font-size: 30px; font-weight: 700; line-height: 1; color: #FFFFFF; font-variant-numeric: tabular-nums; }
|
||||||
|
.lp .countdown .cd-unit { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #B8B6AE; }
|
||||||
|
|
||||||
|
/* CARDS */
|
||||||
|
.lp .cards-wrap { padding: 32px 64px 72px; }
|
||||||
|
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
||||||
|
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
|
||||||
|
.lp .card.featured { border: 2px solid #2D2D2D; }
|
||||||
|
.lp .featured-badge {
|
||||||
|
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
|
||||||
|
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
|
||||||
|
}
|
||||||
|
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
|
||||||
|
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
|
||||||
|
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
|
||||||
|
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
|
||||||
|
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
|
||||||
|
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
|
||||||
|
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
|
||||||
|
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.lp .card .buy {
|
||||||
|
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
|
||||||
|
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
|
||||||
|
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
|
||||||
|
}
|
||||||
|
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
|
||||||
|
|
||||||
|
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
|
||||||
|
|
||||||
|
/* CARD DELLE EDIZIONI A TRE (Opening Bundles): domanda in testa, descrizione e riquadro
|
||||||
|
col periodo di utilizzo. Le card restano in colonna, quindi il bottone finisce ad altezze
|
||||||
|
diverse se i testi hanno lunghezze diverse: `flex` + `margin-top:auto` lo tiene in fondo. */
|
||||||
|
.lp .cards-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.lp .cards-3 .card { display: flex; flex-direction: column; }
|
||||||
|
.lp .cards-3 .card .buy { margin-top: auto; }
|
||||||
|
|
||||||
|
/* CARD DELLE EDIZIONI A CINQUE (promo settembre). Cinque non si divide in una griglia a due
|
||||||
|
o a tre colonne senza lasciare un buco in fondo: la griglia è a sei colonne e ogni card ne
|
||||||
|
occupa due, così le ultime due si spostano al centro invece di restare allineate a sinistra
|
||||||
|
con un vuoto accanto. */
|
||||||
|
.lp .cards-5 { grid-template-columns: repeat(6, 1fr); }
|
||||||
|
.lp .cards-5 .card { grid-column: span 2; display: flex; flex-direction: column; }
|
||||||
|
.lp .cards-5 .card .buy { margin-top: auto; }
|
||||||
|
.lp .cards-5 .card:nth-child(4) { grid-column: 2 / span 2; }
|
||||||
|
.lp .cards-5 .card:nth-child(5) { grid-column: 4 / span 2; }
|
||||||
|
.lp .card .question {
|
||||||
|
margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D;
|
||||||
|
}
|
||||||
|
.lp .card .card-desc {
|
||||||
|
margin: 0 0 18px; font-size: 13px; line-height: 1.6; color: #6E685C; font-style: italic;
|
||||||
|
}
|
||||||
|
.lp .card .note-box { background: #F2ECE1; border-radius: 8px; padding: 10px 12px; margin: 0 0 18px; }
|
||||||
|
.lp .card .note-label {
|
||||||
|
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700;
|
||||||
|
color: #8A6E3F; margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.lp .card .note-text { font-size: 12px; line-height: 1.5; color: #5C564A; }
|
||||||
|
|
||||||
|
/* Pagina senza nessuna promozione attiva: fra un'edizione e la successiva. */
|
||||||
|
.lp .hero.chiusa { padding-bottom: 72px; }
|
||||||
|
.lp .hero.chiusa h1 { font-size: 34px; }
|
||||||
|
.lp .hero.chiusa p.desc { margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* Barra di anteprima: visibile solo a chi è loggato e ha chiesto una data (?anteprima=…).
|
||||||
|
Deve essere impossibile scambiarla per la pagina vera. */
|
||||||
|
.lp .anteprima {
|
||||||
|
background: #8A6E3F; color: #FFFFFF; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
|
||||||
|
padding: 10px 16px; text-align: center;
|
||||||
|
}
|
||||||
|
/* Anteprima chiesta e non applicata: colore diverso, perché il contenuto sotto è quello di
|
||||||
|
oggi e non quello della data richiesta. */
|
||||||
|
.lp .anteprima.ignorata { background: #8B2E2E; }
|
||||||
|
.lp .anteprima.ignorata a { color: #FFFFFF; }
|
||||||
|
|
||||||
|
/* FAQ */
|
||||||
|
.lp .faq-wrap { padding: 72px 64px; }
|
||||||
|
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
|
||||||
|
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
|
||||||
|
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
|
||||||
|
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
|
||||||
|
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
|
||||||
|
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
|
||||||
|
.lp .faq-toggle {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
|
||||||
|
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
|
||||||
|
transition: background .2s ease, color .2s ease, border-color .2s ease;
|
||||||
|
}
|
||||||
|
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
|
||||||
|
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
|
||||||
|
.lp .faq-block.is-open .faq-a { display: block; }
|
||||||
|
.lp .faq-a .hl { color: #8A6E3F; }
|
||||||
|
|
||||||
|
/* Sotto i 1000px tre colonne diventano troppo strette per un prezzo e un elenco: le cinque
|
||||||
|
card passano a due per riga (griglia a quattro, span due), con la quinta al centro.
|
||||||
|
Nessun `min-width` a chiudere la fascia in basso: a viewport frazionari (uno zoom del
|
||||||
|
browser dà larghezze come 700,5px) fra 700 e 701 non varrebbe nessuna delle due regole e
|
||||||
|
le card tornerebbero a sei colonne. Ci pensa il blocco ≤700px, che viene dopo e a pari
|
||||||
|
specificità vince. */
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.lp .cards-5 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.lp .cards-5 .card:nth-child(4) { grid-column: span 2; }
|
||||||
|
.lp .cards-5 .card:nth-child(5) { grid-column: 2 / span 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
|
||||||
|
.lp .cards { grid-template-columns: 1fr; }
|
||||||
|
/* A una colonna gli span e le collocazioni delle ultime due card lascerebbero righe vuote. */
|
||||||
|
.lp .cards-5 .card,
|
||||||
|
.lp .cards-5 .card:nth-child(4),
|
||||||
|
.lp .cards-5 .card:nth-child(5) { grid-column: auto; }
|
||||||
|
.lp .countdown .cd-boxes { gap: 6px; }
|
||||||
|
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
|
||||||
|
.lp .countdown .cd-num { font-size: 22px; }
|
||||||
|
.lp .countdown .cd-unit { font-size: 9.5px; }
|
||||||
|
.lp .hero h1 { font-size: 32px; }
|
||||||
|
.lp .launch h1 { font-size: 28px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../layouts/Base.astro';
|
|
||||||
import PageHero from '../components/PageHero.astro';
|
|
||||||
import T from '../components/content/T.astro';
|
|
||||||
import { t, resolveContent } from '../lib/content';
|
|
||||||
// Variante di test di /promo-eventi ristrutturata sui pattern del sito: banner PageHero
|
|
||||||
// a tutta larghezza, sezioni .section/.container su sfondo bianco, bottoni .btn globali,
|
|
||||||
// font e palette dal design system. Contenuti identici (stessi tag `promo.*`). Gli stili
|
|
||||||
// custom (griglia card, accordion FAQ) sono namespaced sotto `.promo2`.
|
|
||||||
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{ id: 1, features: 4, featured: false },
|
|
||||||
{ id: 2, features: 4, featured: true },
|
|
||||||
{ id: 3, features: 3, featured: false },
|
|
||||||
{ id: 4, features: 3, featured: false },
|
|
||||||
];
|
|
||||||
const faqs = [1, 2, 3, 4, 5, 6];
|
|
||||||
const wa = t('global.whatsapp');
|
|
||||||
---
|
|
||||||
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
|
|
||||||
<PageHero title={t('promo.pagehero.title')} eyebrow={t('promo.hero.badge')} />
|
|
||||||
|
|
||||||
<div class="promo2">
|
|
||||||
|
|
||||||
<!-- Claim principale -->
|
|
||||||
<section class="section claim-section">
|
|
||||||
<div class="claim-wrap">
|
|
||||||
<h2 class="claim">
|
|
||||||
<span class="l1"><T tag="promo.hero.title1" as="span" /></span>
|
|
||||||
<span class="l2"><em><T tag="promo.hero.accent" as="span" /></em> <T tag="promo.hero.title2" as="span" /></span>
|
|
||||||
</h2>
|
|
||||||
<T tag="promo.hero.desc" as="p" class="claim-desc" />
|
|
||||||
<a class="btn" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Lancio + perché 5 -->
|
|
||||||
<section class="section section--alt">
|
|
||||||
<div class="container launch">
|
|
||||||
<T tag="promo.launch.badge" as="p" class="eyebrow" />
|
|
||||||
<h2><T tag="promo.launch.title" as="span" /></h2>
|
|
||||||
<p class="launch-accent"><T tag="promo.launch.accent" as="span" /></p>
|
|
||||||
<T tag="promo.launch.desc" as="p" />
|
|
||||||
<T tag="promo.why5.text" as="p" class="why5" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Pacchetti -->
|
|
||||||
<section class="section" id="proposte">
|
|
||||||
<div class="container">
|
|
||||||
<div class="cards">
|
|
||||||
{cards.map((c) => (
|
|
||||||
<div class:list={['card', c.featured && 'featured']}>
|
|
||||||
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
|
|
||||||
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
|
|
||||||
<T tag={`promo.card.${c.id}.title`} as="div" class="ptitle" />
|
|
||||||
<div class="price-row">
|
|
||||||
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
|
|
||||||
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
|
|
||||||
</div>
|
|
||||||
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
|
|
||||||
<ul>
|
|
||||||
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
|
|
||||||
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<a class:list={['btn', c.featured ? 'btn--dark' : 'btn--light', 'buy']} href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<T tag="promo.validity-note" as="p" class="validity-note" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- FAQ -->
|
|
||||||
<section class="section section--alt">
|
|
||||||
<div class="container faq-wrap">
|
|
||||||
<T tag="promo.faq.title" as="h2" class="faq-title" />
|
|
||||||
{faqs.map((n) => {
|
|
||||||
const a = resolveContent(`promo.faq.${n}.a`);
|
|
||||||
return (
|
|
||||||
<div class="faq-block">
|
|
||||||
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
|
|
||||||
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</Base>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
|
|
||||||
document.querySelectorAll('.promo2 .faq-block .faq-q').forEach((q) => {
|
|
||||||
const block = q.closest('.faq-block');
|
|
||||||
const toggle = q.querySelector('.faq-toggle');
|
|
||||||
q.setAttribute('role', 'button');
|
|
||||||
q.setAttribute('tabindex', '0');
|
|
||||||
q.setAttribute('aria-expanded', 'false');
|
|
||||||
const set = (open: boolean) => {
|
|
||||||
block?.classList.toggle('is-open', open);
|
|
||||||
q.setAttribute('aria-expanded', String(open));
|
|
||||||
if (toggle) toggle.textContent = open ? '−' : '+';
|
|
||||||
};
|
|
||||||
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
|
|
||||||
q.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
|
|
||||||
scope della pagina). Tutto è prefissato `.promo2`; la struttura usa le classi globali
|
|
||||||
del sito (.section, .container, .btn, .eyebrow, .section-heading). -->
|
|
||||||
<style is:global>
|
|
||||||
/* Claim principale */
|
|
||||||
.promo2 .claim-section { padding-block: 72px; text-align: center; }
|
|
||||||
.promo2 .claim-wrap { max-width: 820px; margin: 0 auto; padding-inline: 20px; }
|
|
||||||
.promo2 .claim { font-family: var(--font-heading); font-weight: 500; letter-spacing: .02em;
|
|
||||||
color: var(--c-heading); line-height: 1.18; font-size: clamp(2.1rem, 4.5vw, 3.1rem); margin: 0 0 22px; }
|
|
||||||
.promo2 .claim .l1, .promo2 .claim .l2 { display: block; }
|
|
||||||
.promo2 .claim em { font-style: italic; color: var(--c-accent-dark); }
|
|
||||||
.promo2 .claim-desc { max-width: 500px; margin: 0 auto 30px; font-size: 1.05rem; color: var(--c-text); }
|
|
||||||
|
|
||||||
/* Lancio — banda evidenziata */
|
|
||||||
.promo2 .launch { max-width: 760px; text-align: center; }
|
|
||||||
.promo2 .launch .eyebrow { display: block; margin: 0 0 14px; }
|
|
||||||
.promo2 .launch h2 { margin: 0 0 6px; }
|
|
||||||
.promo2 .launch-accent { font-family: var(--font-heading); font-style: italic; font-size: 1.2rem; color: var(--c-accent-dark); margin: 0 0 22px; }
|
|
||||||
.promo2 .launch > p { color: var(--c-text); margin-inline: auto; max-width: 620px; }
|
|
||||||
.promo2 .why5 { font-family: var(--font-heading); font-style: italic; font-size: 1.25rem; line-height: 1.65;
|
|
||||||
color: var(--c-heading); margin: 32px auto 0; max-width: 600px; padding-top: 28px; border-top: 2px solid var(--c-accent); }
|
|
||||||
|
|
||||||
/* Pacchetti */
|
|
||||||
.promo2 .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; align-items: stretch; }
|
|
||||||
.promo2 .card { background: var(--c-bg); border: 1px solid #e7e0d6; border-radius: 6px; padding: 34px 30px; position: relative; display: flex; flex-direction: column; transition: box-shadow .25s, transform .25s; }
|
|
||||||
.promo2 .card:hover { box-shadow: 0 14px 40px rgba(58,41,41,.10); transform: translateY(-3px); }
|
|
||||||
.promo2 .card.featured { border: 1px solid var(--c-accent); box-shadow: 0 10px 34px rgba(58,41,41,.08); }
|
|
||||||
.promo2 .featured-badge {
|
|
||||||
position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--c-accent);
|
|
||||||
color: #fff; font-family: var(--font-heading); font-size: 10.5px; font-weight: 600; letter-spacing: .12em;
|
|
||||||
text-transform: uppercase; padding: 6px 16px; border-radius: 999px; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.promo2 .card .eyebrow { margin: 0 0 8px; font-size: .68rem; }
|
|
||||||
.promo2 .card .ptitle { font-family: var(--font-heading); font-size: 1.4rem; font-weight: 500; color: var(--c-heading); letter-spacing: .03em; margin: 0 0 18px; }
|
|
||||||
.promo2 .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 3px; }
|
|
||||||
.promo2 .card .price { font-family: var(--font-heading); font-size: 2.4rem; font-weight: 600; color: var(--c-heading); line-height: 1; }
|
|
||||||
.promo2 .card .price-old { font-size: 1.05rem; color: var(--c-text-light); text-decoration: line-through; }
|
|
||||||
.promo2 .card .unit { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--c-text-light); margin: 0 0 22px; }
|
|
||||||
.promo2 .card ul { list-style: none; padding: 22px 0 0; margin: 0 0 26px; border-top: 1px solid #f0eae1; display: flex; flex-direction: column; gap: 11px; }
|
|
||||||
.promo2 .card li { font-size: .9rem; line-height: 1.5; color: var(--c-text); display: flex; gap: 10px; }
|
|
||||||
.promo2 .card li::before { content: "—"; color: var(--c-accent); font-weight: 700; flex-shrink: 0; }
|
|
||||||
.promo2 .card .buy { margin-top: auto; justify-content: center; }
|
|
||||||
.promo2 .validity-note { text-align: center; font-size: .82rem; color: var(--c-text-light); margin: 34px auto 0; max-width: 560px; }
|
|
||||||
|
|
||||||
/* FAQ */
|
|
||||||
.promo2 .faq-wrap { max-width: 760px; }
|
|
||||||
.promo2 .faq-title { text-align: center; text-transform: uppercase; letter-spacing: .1em; font-size: clamp(1.5rem, 3vw, 2rem); margin: 0 0 44px; }
|
|
||||||
.promo2 .faq-block { border-top: 1px solid #e4ded6; padding: 20px 4px; transition: padding .2s; }
|
|
||||||
.promo2 .faq-block:last-child { border-bottom: 1px solid #e4ded6; }
|
|
||||||
.promo2 .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
|
|
||||||
.promo2 .faq-q p { margin: 0; font-family: var(--font-heading); font-size: 1rem; font-weight: 500; color: var(--c-heading); letter-spacing: .02em; }
|
|
||||||
.promo2 .faq-q:focus-visible { outline: 2px solid var(--c-accent-dark); outline-offset: 4px; }
|
|
||||||
.promo2 .faq-toggle {
|
|
||||||
width: 32px; height: 32px; border-radius: 50%; border: 1px solid #d8cfc2; display: flex;
|
|
||||||
align-items: center; justify-content: center; flex-shrink: 0; font-size: 17px; color: var(--c-heading);
|
|
||||||
transition: background .2s ease, color .2s ease, border-color .2s ease;
|
|
||||||
}
|
|
||||||
.promo2 .faq-block.is-open .faq-toggle { background: var(--c-accent); color: #fff; border-color: var(--c-accent); }
|
|
||||||
.promo2 .faq-a { display: none; margin: 14px 0 0; font-size: .94rem; line-height: 1.75; color: var(--c-text); max-width: 640px; }
|
|
||||||
.promo2 .faq-block.is-open .faq-a { display: block; }
|
|
||||||
.promo2 .faq-a .hl { color: var(--c-accent-dark); font-weight: 600; }
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.promo2 .cards { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -7,13 +7,22 @@ import { performanceClassPricing } from '../../data/pricing';
|
|||||||
import { serviceIcons } from '../../data/service-icons';
|
import { serviceIcons } from '../../data/service-icons';
|
||||||
import T from '../../components/content/T.astro';
|
import T from '../../components/content/T.astro';
|
||||||
import TImg from '../../components/content/TImg.astro';
|
import TImg from '../../components/content/TImg.astro';
|
||||||
|
import CalendarioModal from '../../components/CalendarioModal.astro';
|
||||||
|
import { calendariDaMostrare, PAGINE_CON_CALENDARIO } from '../../data/calendari';
|
||||||
import { t } from '../../lib/content';
|
import { t } from '../../lib/content';
|
||||||
|
import { PAGINE_CON_CONSULENZA, linkConsulenza } from '../../data/consulenza';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const service = getService(Astro.params.slug!);
|
const service = getService(Astro.params.slug!);
|
||||||
if (!service) return new Response(null, { status: 404 });
|
// `page: false` = servizio senza scheda (oggi la Performance Class, che non si eroga più):
|
||||||
|
// la voce esiste ancora nei dati per la card in griglia, ma la pagina non c'è.
|
||||||
|
if (!service || service.page === false) return new Response(null, { status: 404 });
|
||||||
const slug = service.slug;
|
const slug = service.slug;
|
||||||
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false);
|
// Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è
|
||||||
|
// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è
|
||||||
|
// peggio di nessun link.
|
||||||
|
const conCalendario = PAGINE_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0;
|
||||||
|
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false && p.page !== false);
|
||||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
||||||
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||||
const heroImg = imgOf(service.slug);
|
const heroImg = imgOf(service.slug);
|
||||||
@@ -23,13 +32,26 @@ const categoryIndexes = [1, 2, 3, 4];
|
|||||||
const tones = ['tan', 'taupe', 'dark'];
|
const tones = ['tan', 'taupe', 'dark'];
|
||||||
---
|
---
|
||||||
<Base title={t(`service.${slug}.title`)} description={t(`service.${slug}.excerpt`)}>
|
<Base title={t(`service.${slug}.title`)} description={t(`service.${slug}.excerpt`)}>
|
||||||
<PageHero title={t(`service.${slug}.title`).toUpperCase()} bgImage={heroImg} />
|
<PageHero title={(service.heroTitle ?? t(`service.${slug}.title`)).toUpperCase()} bgImage={heroImg} />
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container prog">
|
<div class="container prog">
|
||||||
<div>
|
<div>
|
||||||
<h2>{t(`service.${slug}.title`).toUpperCase()}</h2>
|
<h2>{t(`service.${slug}.title`).toUpperCase()}</h2>
|
||||||
<T tag={`service.${slug}.long-description`} as="p" class="prog__desc" />
|
<T tag={`service.${slug}.long-description`} as="p" class="prog__desc" />
|
||||||
|
{(slug === 'reformer-class' || PAGINE_CON_CONSULENZA.includes(slug)) && (
|
||||||
|
<div class="prog__cta">
|
||||||
|
<div class="prog__cta-row">
|
||||||
|
{slug === 'reformer-class' && <a class="btn" href="/contact">{t('service.contact-cta')}</a>}
|
||||||
|
{PAGINE_CON_CONSULENZA.includes(slug) && (
|
||||||
|
<div class="prog__cta-consulenza">
|
||||||
|
<a class="btn btn--consulenza" href={linkConsulenza(t('global.consulenza.url'))} target="_blank" rel="noopener noreferrer">{t('service.consulenza-cta')}</a>
|
||||||
|
<T tag="service.consulenza-note" as="p" class="prog__cta-note" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{service.features.length > 0 && (
|
{service.features.length > 0 && (
|
||||||
<ul class="prog__features">
|
<ul class="prog__features">
|
||||||
{service.features.map((_, i) => <T tag={`service.${slug}.feature.${i + 1}`} as="li" />)}
|
{service.features.map((_, i) => <T tag={`service.${slug}.feature.${i + 1}`} as="li" />)}
|
||||||
@@ -38,6 +60,15 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
{heroImg && (
|
{heroImg && (
|
||||||
<TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
|
<TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
|
||||||
)}
|
)}
|
||||||
|
{service.extra && (
|
||||||
|
<div class="prog__extra">
|
||||||
|
<T tag={`service.${slug}.extra.title`} as="h2" />
|
||||||
|
<T tag={`service.${slug}.extra.body`} as="p" class="prog__desc" />
|
||||||
|
<ul class="prog__features">
|
||||||
|
{service.extra.items.map((_, i) => <T tag={`service.${slug}.extra.item.${i + 1}`} as="li" />)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="prog__faq">
|
<div class="prog__faq">
|
||||||
{service.faq.map((_, i) => (
|
{service.faq.map((_, i) => (
|
||||||
<details open={i === 0}>
|
<details open={i === 0}>
|
||||||
@@ -59,6 +90,16 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
<T tag={`service.${o.slug}.title`} />
|
<T tag={`service.${o.slug}.title`} />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
{conCalendario && (
|
||||||
|
<button type="button" class="prog__calendario" data-apri-calendario>
|
||||||
|
<span class="prog__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true">
|
||||||
|
<path d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4M8 14h3M8 17h3M14 14h2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{t('calendario.link-label')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -95,12 +136,16 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{conCalendario && <CalendarioModal />}
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
|
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
|
||||||
.prog :global(.prog__desc) { white-space: pre-line; }
|
.prog :global(.prog__desc) { white-space: pre-line; }
|
||||||
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
|
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
|
||||||
|
.prog__extra { padding-top: 30px; margin-bottom: 10px; }
|
||||||
|
.prog__extra h2 { letter-spacing: .1em; margin-bottom: .5em; }
|
||||||
.prog__features li::marker { color: var(--c-accent); }
|
.prog__features li::marker { color: var(--c-accent); }
|
||||||
.prog__img { width: 100%; height: auto; margin-bottom: 30px; }
|
.prog__img { width: 100%; height: auto; margin-bottom: 30px; }
|
||||||
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
|
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
|
||||||
@@ -109,6 +154,20 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
.prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; }
|
.prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; }
|
||||||
.prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; }
|
.prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
.prog__other:hover { color: var(--c-accent-dark); }
|
.prog__other:hover { color: var(--c-accent-dark); }
|
||||||
|
/* Il calendario dei corsi (popup) è staccato dall'elenco degli altri servizi e pieno
|
||||||
|
nel colore d'accento: il cliente lo vuole evidente (InsanityLab-web#19). Taupe e non
|
||||||
|
--c-dark: Adriano 29/09 lo vuole meno scuro; bianco su #6b5d5b resta leggibile (~6:1). */
|
||||||
|
.prog__calendario { display: flex; align-items: center; gap: 14px; width: 100%; margin-top: 36px; padding: 10px 16px 10px 10px; border: 0; cursor: pointer; text-align: left; background: #6b5d5b; color: #fff; font-family: var(--font-heading); font-size: .82rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; transition: background .2s; }
|
||||||
|
.prog__calendario:hover { background: var(--c-accent-dark); }
|
||||||
|
.prog__cta { margin: 10px 0 30px; }
|
||||||
|
.prog__cta-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
|
||||||
|
/* La nota (durata, sede) sta sotto il suo bottone, non sotto Contattaci (Adriano 29/09). */
|
||||||
|
.prog__cta-consulenza { display: flex; flex-direction: column; }
|
||||||
|
.btn--consulenza { background: #6b5d5b; }
|
||||||
|
.btn--consulenza:hover { background: var(--c-accent-dark); }
|
||||||
|
/* Il paragrafo lo stampa il componente T: senza :global lo stile scoped della pagina non lo raggiunge. */
|
||||||
|
.prog__cta :global(.prog__cta-note) { font-size: .8rem; color: var(--c-text-light); margin: 8px 0 0; }
|
||||||
|
@media (max-width: 600px) { .prog__cta-row > .btn, .prog__cta-consulenza { flex: 1 1 100%; } .prog__cta-row .btn { justify-content: center; text-align: center; } }
|
||||||
.prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; }
|
.prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; }
|
||||||
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
|
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
|
||||||
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
|
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import type { ImageMetadata } from 'astro';
|
|||||||
import { services } from '../../data/services';
|
import { services } from '../../data/services';
|
||||||
import T from '../../components/content/T.astro';
|
import T from '../../components/content/T.astro';
|
||||||
import TImg from '../../components/content/TImg.astro';
|
import TImg from '../../components/content/TImg.astro';
|
||||||
|
import CalendarioModal from '../../components/CalendarioModal.astro';
|
||||||
|
import { calendariDaMostrare, CARD_CON_CALENDARIO } from '../../data/calendari';
|
||||||
import { t } from '../../lib/content';
|
import { t } from '../../lib/content';
|
||||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
||||||
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||||
const gridServices = services.filter((p) => p.grid !== false);
|
const gridServices = services.filter((p) => p.grid !== false);
|
||||||
// Questi servizi rimandano alla pagina Training invece che alla scheda di dettaglio
|
// Foto e "Per saperne di più" della stessa card portano allo stesso posto, e lo fanno per
|
||||||
const TRAINING_LINK = new Set(['one-to-one', 'performance-class']);
|
// costruzione: erano due espressioni diverse e sono divergute — su One to One e Performance
|
||||||
const moreHref = (slug: string) => (TRAINING_LINK.has(slug) ? '/training' : `/services/${slug}`);
|
// Class il testo mandava a /training mentre la foto apriva la scheda del servizio, così la
|
||||||
|
// destinazione dipendeva da dove capitava il dito.
|
||||||
|
// Alcune card non portano alla propria scheda ma a una vista di /training (One to One e
|
||||||
|
// Class, deciso dal cliente il 17/08/2026): la destinazione la dichiara il servizio.
|
||||||
|
const hrefOf = (p: (typeof services)[number]) => p.href ?? `/services/${p.slug}`;
|
||||||
|
// Il calendario delle class si apre anche da qui, dalla card Class: chi guarda i servizi
|
||||||
|
// vuole sapere quando si allena prima ancora di aprire la scheda. Compare solo se c'è
|
||||||
|
// davvero una griglia da mostrare, come nelle pagine delle class.
|
||||||
|
const conCalendario = calendariDaMostrare(new Date()).length > 0;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
---
|
---
|
||||||
<Base title={t('services.meta.title')} description={t('services.meta.description')}>
|
<Base title={t('services.meta.title')} description={t('services.meta.description')}>
|
||||||
@@ -25,7 +35,7 @@ export const prerender = false;
|
|||||||
{gridServices.map((p, i) => (
|
{gridServices.map((p, i) => (
|
||||||
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
|
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
|
||||||
{imgOf(p.slug) && (
|
{imgOf(p.slug) && (
|
||||||
<a class="plist__photo" href={`/services/${p.slug}`}>
|
<a class="plist__photo" href={hrefOf(p)}>
|
||||||
<TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
|
<TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -33,13 +43,17 @@ export const prerender = false;
|
|||||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||||
<T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
|
<T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
|
||||||
<T tag={`service.${p.slug}.excerpt`} as="p" />
|
<T tag={`service.${p.slug}.excerpt`} as="p" />
|
||||||
<a class="plist__more" href={moreHref(p.slug)}>{t('services.card.more-label')}</a>
|
{conCalendario && CARD_CON_CALENDARIO.includes(p.slug) && (
|
||||||
|
<button type="button" class="plist__calendario" data-apri-calendario>{t('calendario.link-label')}</button>
|
||||||
|
)}
|
||||||
|
<a class="plist__more" href={hrefOf(p)}>{t('services.card.more-label')}</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{conCalendario && <CalendarioModal />}
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -51,9 +65,15 @@ export const prerender = false;
|
|||||||
.plist__body { padding: 26px 24px 30px; display: flex; flex-direction: column; align-items: flex-start; flex: 1; }
|
.plist__body { padding: 26px 24px 30px; display: flex; flex-direction: column; align-items: flex-start; flex: 1; }
|
||||||
.plist__card h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .3em; }
|
.plist__card h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .3em; }
|
||||||
.plist__sub { font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
.plist__sub { font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
||||||
.plist__body > p:not(.plist__sub) { font-size: .9rem; }
|
.plist__body > p:not(.plist__sub) { font-size: .9rem; white-space: pre-line; }
|
||||||
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); margin-top: auto; }
|
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); margin-top: auto; }
|
||||||
.plist__more:hover { color: var(--c-accent-dark); }
|
.plist__more:hover { color: var(--c-accent-dark); }
|
||||||
|
/* Il calendario sta sopra "Per saperne di più" (posizione chiesta dal cliente) e apre un
|
||||||
|
popup, quindi è un bottone: qui gli si toglie l'aspetto da bottone perché nella card
|
||||||
|
stia accanto all'altra voce senza rubarle l'occhio. */
|
||||||
|
.plist__calendario { background: none; border: 0; padding: 0; margin-top: auto; cursor: pointer; text-align: left; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent-dark); }
|
||||||
|
.plist__calendario:hover { color: var(--c-heading); }
|
||||||
|
.plist__calendario + .plist__more { margin-top: 10px; }
|
||||||
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
|
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
|
||||||
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
|
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import PageHero from '../components/PageHero.astro';
|
import PageHero from '../../components/PageHero.astro';
|
||||||
import TrainingCards from '../components/home/TrainingCards.astro';
|
import TrainingCards from '../../components/home/TrainingCards.astro';
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from 'astro:assets';
|
||||||
import { trainings } from '../data/trainings';
|
import type { ImageMetadata } from 'astro';
|
||||||
import T from '../components/content/T.astro';
|
import { VISTE_TRAINING, trainingDaMostrare } from '../../data/trainings';
|
||||||
import { t } from '../lib/content';
|
import T from '../../components/content/T.astro';
|
||||||
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
|
import { t } from '../../lib/content';
|
||||||
|
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
||||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
|
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
|
// `/training` mostra tutto; `/training/one-to-one` e `/training/class` il solo sottoinsieme
|
||||||
|
// da cui si arriva cliccando la card corrispondente in /services. Un indirizzo inventato è
|
||||||
|
// 404 e non l'elenco intero: un filtro che non c'è deve vedersi, non passare per buono.
|
||||||
|
const vista = Astro.params.vista;
|
||||||
|
const daMostrare = trainingDaMostrare(vista);
|
||||||
|
if (!daMostrare) return new Response(null, { status: 404 });
|
||||||
|
const filtrata = Boolean(vista);
|
||||||
---
|
---
|
||||||
<Base title={t('training.meta.title')} description={t('training.meta.description')}>
|
<Base title={t('training.meta.title')} description={t('training.meta.description')}>
|
||||||
<PageHero title={t('training.hero.title')} />
|
<PageHero title={t('training.hero.title')} />
|
||||||
|
|
||||||
<section class="section" style="padding-bottom:0">
|
<section class="section" style="padding-bottom:0">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<T tag="training.intro.subtitle" as="p" />
|
{filtrata ? <p>{VISTE_TRAINING[vista!].titolo}</p> : <T tag="training.intro.subtitle" as="p" />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<TrainingCards />
|
<TrainingCards items={daMostrare} />
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{trainings.map((tr, i) => (
|
{daMostrare.map((tr, i) => (
|
||||||
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
|
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
|
||||||
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
|
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
|
||||||
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
|
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
|
||||||
@@ -32,6 +41,11 @@ export const prerender = false;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* Da una vista filtrata si esce: senza questo link chi è entrato da una card di
|
||||||
|
/services non ha modo di vedere gli altri training se non dal menu. */}
|
||||||
|
{filtrata && (
|
||||||
|
<p class="tblock__tutti"><a class="btn btn--light" href="/training">{t('training.tutti.cta-label')}</a></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
@@ -40,5 +54,6 @@ export const prerender = false;
|
|||||||
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
|
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
|
||||||
.tblock--reverse img { order: 2; }
|
.tblock--reverse img { order: 2; }
|
||||||
.tblock h2 { letter-spacing: .1em; }
|
.tblock h2 { letter-spacing: .1em; }
|
||||||
|
.tblock__tutti { text-align: center; margin-top: 20px; }
|
||||||
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
|
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { avvisi, avvisiAttivi, calendari, calendariDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
|
||||||
|
|
||||||
|
const d = (iso: string) => new Date(iso);
|
||||||
|
|
||||||
|
describe('calendariDaMostrare', () => {
|
||||||
|
const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`);
|
||||||
|
|
||||||
|
// Si vedono tutti insieme, non solo quello del momento: chi guarda ad agosto vuole sapere
|
||||||
|
// com'è settembre per intero, e una griglia sola gli farebbe credere che valga per sempre.
|
||||||
|
it('prima dell\'inizio: entrambi, marcati futuri', () => {
|
||||||
|
expect(idDi('2026-08-17T10:00:00+02:00'))
|
||||||
|
.toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => {
|
||||||
|
expect(idDi('2026-09-05T10:00:00+02:00'))
|
||||||
|
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un orario scaduto non deve restare in pagina: farebbe cercare lezioni che non ci sono più.
|
||||||
|
it('finito il primo periodo, il primo orario sparisce', () => {
|
||||||
|
expect(idDi('2026-09-25T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
|
||||||
|
expect(idDi('2027-03-01T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class:
|
||||||
|
// il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08).
|
||||||
|
it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => {
|
||||||
|
expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||||
|
expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confini al secondo, in ora italiana', () => {
|
||||||
|
expect(idDi('2026-09-18T23:59:59+02:00'))
|
||||||
|
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
|
||||||
|
expect(idDi('2026-09-19T00:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||||
|
expect(idDi('2026-09-21T00:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('l\'ordine dell\'elenco di partenza non cambia il risultato', () => {
|
||||||
|
const rovescio = [...calendari].reverse();
|
||||||
|
expect(calendariDaMostrare(d('2026-09-05T10:00:00+02:00'), rovescio).map((m) => m.cal.id))
|
||||||
|
.toEqual(['settembre-1-18', 'settembre-21-in-poi']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('giornate fuori griglia', () => {
|
||||||
|
it('la presentazione e la chiusura si vedono prima di arrivarci', () => {
|
||||||
|
const oggi = avvisiAttivi(d('2026-08-17T12:00:00+02:00')).map((a) => a.id);
|
||||||
|
expect(oggi).toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un avviso passato è peggio di nessun avviso: dice che il centro è chiuso un giorno che è
|
||||||
|
// già trascorso, e chi legge non sa più se valga per quest'anno o per il prossimo.
|
||||||
|
it('ogni avviso sparisce da solo quando è passato', () => {
|
||||||
|
expect(avvisiAttivi(d('2026-09-19T23:59:59+02:00')).map((a) => a.id))
|
||||||
|
.toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
|
||||||
|
expect(avvisiAttivi(d('2026-09-20T00:00:00+02:00')).map((a) => a.id))
|
||||||
|
.toEqual(['chiusura-20-settembre']);
|
||||||
|
expect(avvisiAttivi(d('2026-09-21T00:00:00+02:00'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ogni avviso ha una scadenza leggibile e un testo', () => {
|
||||||
|
for (const a of avvisi) {
|
||||||
|
expect(Number.isNaN(Date.parse(a.to)), `${a.id}: scadenza illeggibile`).toBe(false);
|
||||||
|
if (a.from !== null) expect(Date.parse(a.to)).toBeGreaterThanOrEqual(Date.parse(a.from));
|
||||||
|
expect(a.quando.length).toBeGreaterThan(0);
|
||||||
|
expect(a.testo.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coerenza dei calendari trascritti', () => {
|
||||||
|
for (const c of calendari) {
|
||||||
|
describe(c.id, () => {
|
||||||
|
it('la finestra è leggibile e ha un verso', () => {
|
||||||
|
expect(Number.isNaN(Date.parse(c.from))).toBe(false);
|
||||||
|
if (c.to !== null) {
|
||||||
|
expect(Number.isNaN(Date.parse(c.to))).toBe(false);
|
||||||
|
expect(Date.parse(c.to)).toBeGreaterThan(Date.parse(c.from));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le fasce orarie sono uniche e in ordine crescente', () => {
|
||||||
|
const ore = c.righe.map((r) => r.ora);
|
||||||
|
expect(new Set(ore).size, 'fascia ripetuta').toBe(ore.length);
|
||||||
|
const minuti = ore.map((o) => {
|
||||||
|
const [h, m] = o.split('.');
|
||||||
|
return Number(h) * 60 + Number(m);
|
||||||
|
});
|
||||||
|
expect(minuti.every((v, i) => i === 0 || v > minuti[i - 1]), `fasce fuori ordine: ${ore.join(' ')}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ogni cella sta su un giorno esistente e porta una disciplina nota', () => {
|
||||||
|
for (const r of c.righe) {
|
||||||
|
for (const [giorno, disciplina] of Object.entries(r.celle)) {
|
||||||
|
expect(GIORNI, `${r.ora}: giorno ${giorno} inesistente`).toContain(giorno as Giorno);
|
||||||
|
expect(DISCIPLINE, `${r.ora} ${giorno}: disciplina ${disciplina} sconosciuta`).toContain(disciplina!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Una lezione in un giorno di chiusura è la contraddizione che nessuno nota leggendo la
|
||||||
|
// griglia: la colonna è barrata e la cella ci finisce dentro lo stesso.
|
||||||
|
it('nessuna lezione cade in un giorno di chiusura', () => {
|
||||||
|
for (const r of c.righe) {
|
||||||
|
for (const g of c.chiusi) {
|
||||||
|
expect(r.celle[g], `${r.ora}: lezione di ${g}, che è chiuso`).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le note dei giorni si riferiscono a giorni aperti', () => {
|
||||||
|
for (const g of Object.keys(c.noteGiorno ?? {}) as Giorno[]) {
|
||||||
|
expect(GIORNI).toContain(g);
|
||||||
|
expect(c.chiusi, `nota su ${g}, che è dichiarato chiuso`).not.toContain(g);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { avvisi, calendari } from '../src/data/calendari';
|
||||||
|
import { generaCalendariPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
|
||||||
|
|
||||||
|
describe('PDF del calendario', () => {
|
||||||
|
for (const cal of calendari) {
|
||||||
|
it(`${cal.id}: produce un PDF valido`, async () => {
|
||||||
|
const pdf = await generaCalendariPdf([cal]);
|
||||||
|
// Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse
|
||||||
|
// chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al
|
||||||
|
// browser, che mostrerebbe una pagina bianca invece di un errore.
|
||||||
|
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
|
||||||
|
expect(pdf.subarray(-8).toString()).toContain('%%EOF');
|
||||||
|
expect(pdf.length).toBeGreaterThan(2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a
|
||||||
|
// cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto
|
||||||
|
// /Type /Pages, che dichiara quante pagine contiene.
|
||||||
|
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
|
||||||
|
for (const cal of calendari) {
|
||||||
|
const pdf = await generaCalendariPdf([cal], avvisi);
|
||||||
|
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
|
||||||
|
expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull();
|
||||||
|
expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il nome del file distingue i due periodi', () => {
|
||||||
|
const nomi = calendari.map((c) => nomeFilePdf([c]));
|
||||||
|
expect(new Set(nomi).size).toBe(nomi.length);
|
||||||
|
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
|
||||||
|
// Più orari insieme: un nome che nominasse solo il primo farebbe credere che dentro ci
|
||||||
|
// sia quello e basta.
|
||||||
|
expect(nomeFilePdf(calendari)).toBe('insanitylab-calendario-class.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un orario per pagina: due griglie sullo stesso foglio si confonderebbero fra loro, e
|
||||||
|
// spezzarne una a metà toglie proprio ciò che si va a cercare, l'incrocio giorno/ora.
|
||||||
|
it('con più orari fa una pagina per ciascuno', async () => {
|
||||||
|
const pdf = await generaCalendariPdf(calendari, avvisi);
|
||||||
|
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
|
||||||
|
expect(conteggio).not.toBeNull();
|
||||||
|
expect(Number(conteggio![1])).toBe(calendari.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { linkConsulenza, CONSULENZA_URL_DEFAULT, PAGINE_CON_CONSULENZA } from '../src/data/consulenza';
|
||||||
|
import { contentSeed } from '../src/data/content-seed';
|
||||||
|
import { services } from '../src/data/services';
|
||||||
|
|
||||||
|
describe('consulenza conoscitiva', () => {
|
||||||
|
it('accetta un link https, anche con spazi attorno', () => {
|
||||||
|
expect(linkConsulenza('https://example.com/x')).toBe('https://example.com/x');
|
||||||
|
expect(linkConsulenza(' https://example.com/x ')).toBe('https://example.com/x');
|
||||||
|
});
|
||||||
|
it.each(['javascript:alert(1)', 'http://x.it', '', ' '])('scarta %j e usa il default', (v) => {
|
||||||
|
expect(linkConsulenza(v)).toBe(CONSULENZA_URL_DEFAULT);
|
||||||
|
});
|
||||||
|
it('il seed ha i tre tag', () => {
|
||||||
|
const v = (tag: string) => contentSeed.find((e) => e.tag === tag)?.value;
|
||||||
|
expect(v('service.consulenza-cta')).toBe('Prenota una consulenza conoscitiva gratuita');
|
||||||
|
expect(v('service.consulenza-note')).toBe('30-45 minuti con Nicola, in sede: via Leandro Alberti 76, primo piano');
|
||||||
|
expect(v('global.consulenza.url')).toBe(CONSULENZA_URL_DEFAULT);
|
||||||
|
});
|
||||||
|
it('le pagine con consulenza esistono', () => {
|
||||||
|
expect(PAGINE_CON_CONSULENZA).toContain('reformer-class');
|
||||||
|
for (const slug of PAGINE_CON_CONSULENZA) {
|
||||||
|
const s = services.find((x) => x.slug === slug);
|
||||||
|
expect(s, slug).toBeDefined();
|
||||||
|
expect(s!.page).not.toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Il sito nomina l'area riservata in UN posto solo (l'header). Dopo la separazione quel
|
||||||
|
// link esce dal dominio, quindi non lo protegge piu' nessuna rotta interna: se punta a un
|
||||||
|
// host che non esiste, il menu del sito porta a un errore di DNS e nessun test se ne
|
||||||
|
// accorge. Qui si verifica che sia l'host in uso, non quello previsto per il futuro.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const header = readFileSync(join(process.cwd(), 'src/components/Header.astro'), 'utf8');
|
||||||
|
|
||||||
|
describe("il link all'area riservata", () => {
|
||||||
|
it('punta a un host che esiste davvero oggi', () => {
|
||||||
|
expect(header).toContain('https://piattaforme.tielogic.xyz');
|
||||||
|
// apps.insanitylab.it e' l'indirizzo definitivo, ma il record A su Aruba non c'e'
|
||||||
|
// ancora: finche' non c'e', qui sarebbe un link morto dentro al menu del sito.
|
||||||
|
expect(header).not.toContain('https://apps.insanitylab.it');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non prova piu a costruire un menu delle piattaforme dentro al sito', () => {
|
||||||
|
// Le tre piattaforme non vivono piu' qui: un menu che le elenca sarebbe un elenco di
|
||||||
|
// rotte inesistenti, servite dal sito come 404.
|
||||||
|
expect(header).not.toContain("data/piattaforme");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// L'indirizzo della pagina evento e' stampato dentro il QR code delle locandine affisse
|
||||||
|
// in giro per Bologna: una volta in strada non si corregge piu'. Questi test tengono
|
||||||
|
// insieme le tre cose che devono restare d'accordo — la rotta servita dal sito, l'URL
|
||||||
|
// cucito nel QR e il file che la pagina offre in download.
|
||||||
|
const ROTTA = 'src/pages/promo-eventi/eventi.astro';
|
||||||
|
const URL_STAMPATO = 'https://insanitylab.it/promo-eventi/eventi';
|
||||||
|
|
||||||
|
describe('locandina In-Sanity Hour', () => {
|
||||||
|
it('la rotta che il QR indica esiste', () => {
|
||||||
|
expect(existsSync(ROTTA)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il generatore della locandina punta a quella rotta', () => {
|
||||||
|
const script = readFileSync('scripts/locandina-evento.py', 'utf-8');
|
||||||
|
expect(script).toContain(`URL_EVENTO = '${URL_STAMPATO}'`);
|
||||||
|
// La rotta del file deve corrispondere alla coda dell'URL, altrimenti il QR
|
||||||
|
// porterebbe altrove: /promo-eventi/eventi <- src/pages/promo-eventi/eventi.astro
|
||||||
|
const daFile = '/' + ROTTA.replace('src/pages/', '').replace('.astro', '');
|
||||||
|
expect(URL_STAMPATO.endsWith(daFile)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la locandina che la pagina offre in download esiste', () => {
|
||||||
|
const pagina = readFileSync(ROTTA, 'utf-8');
|
||||||
|
const link = pagina.match(/href="(\/locandina-[^"]+\.pdf)"/);
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(existsSync(`public${link![1]}`)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createDb } from '../src/lib/db';
|
||||||
|
import { services, getService } from '../src/data/services';
|
||||||
|
import { trainings, trainingDaMostrare, VISTE_TRAINING } from '../src/data/trainings';
|
||||||
|
import { contentSeed } from '../src/data/content-seed';
|
||||||
|
import { site } from '../src/data/site';
|
||||||
|
import { PAGINE_CON_CALENDARIO, CARD_CON_CALENDARIO } from '../src/data/calendari';
|
||||||
|
import { rinominaSmallGroup, calcola, applica, TESTI } from '../scripts/testi-17-agosto.mjs';
|
||||||
|
|
||||||
|
// Le modifiche chieste dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
|
||||||
|
|
||||||
|
describe('Small Group è diventato Personalized Training Group', () => {
|
||||||
|
it('non resta traccia del vecchio nome nei dati né nel seed', () => {
|
||||||
|
const testi = [
|
||||||
|
...services.flatMap((s) => [s.title, s.subtitle, s.excerpt, s.longDescription, ...s.features, ...s.faq.flatMap((f) => [f.q, f.a])]),
|
||||||
|
...trainings.flatMap((t) => [t.title, t.caption, t.description]),
|
||||||
|
...site.footerTraining.map((v) => v.label),
|
||||||
|
...contentSeed.map((e) => e.value),
|
||||||
|
];
|
||||||
|
expect(testi.filter((v) => /small group/i.test(v))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lo slug resta `small-group`: è l'indirizzo della scheda, e cambiarlo romperebbe i link
|
||||||
|
// già girati su WhatsApp senza che nessuno se ne accorga.
|
||||||
|
it('lo slug della scheda non cambia', () => {
|
||||||
|
expect(getService('small-group')?.title).toBe('Personalized Training Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gli articoli restano corretti, e passarci due volte non cambia niente', () => {
|
||||||
|
const casi: [string, string][] = [
|
||||||
|
['Lo Small Group di InsanityLab è un allenamento', 'Il Personalized Training Group di InsanityLab è un allenamento'],
|
||||||
|
['nuovi format come gli small group e una', 'nuovi format come i Personalized Training Group e una'],
|
||||||
|
['One to One / Small Group / Rehab', 'One to One / Personalized Training Group / Rehab'],
|
||||||
|
['Small Groups', 'Personalized Training Group'],
|
||||||
|
['in formula small group (massimo 4 persone)', 'in formula Personalized Training Group (massimo 4 persone)'],
|
||||||
|
];
|
||||||
|
for (const [prima, dopo] of casi) {
|
||||||
|
expect(rinominaSmallGroup(prima)).toBe(dopo);
|
||||||
|
expect(rinominaSmallGroup(dopo)).toBe(dopo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lo script porta i testi nuovi in un database già scritto', () => {
|
||||||
|
it('aggiorna le righe vecchie e al secondo giro non ha più niente da fare', () => {
|
||||||
|
const db = createDb(':memory:');
|
||||||
|
const scrivi = db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
|
||||||
|
scrivi.run('service.one-to-one.subtitle', 'text', 'One to One / Small Group / Rehab');
|
||||||
|
scrivi.run('service.profilazione.subtitle', 'text', 'Profilazione e valutazione fisica');
|
||||||
|
scrivi.run('footer.services.4.label', 'text', 'Profilazione');
|
||||||
|
|
||||||
|
const cambi = applica(db);
|
||||||
|
const dopo = (tag: string) => (db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
|
||||||
|
expect(dopo('service.one-to-one.subtitle')).toBe('One to One / Personalized Training Group / Rehab');
|
||||||
|
expect(dopo('service.profilazione.subtitle')).toBe('Base e avanzato');
|
||||||
|
expect(dopo('footer.services.4.label')).toBe('Check-up');
|
||||||
|
expect(cambi.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
expect(calcola(db)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un `--dry-run` che scrivesse renderebbe inutile guardare prima di toccare la produzione.
|
||||||
|
it('con --dry-run non tocca niente', () => {
|
||||||
|
const db = createDb(':memory:');
|
||||||
|
db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)')
|
||||||
|
.run('promo.card.2.title', 'text', 'Small Group');
|
||||||
|
expect(applica(db, { dryRun: true }).length).toBe(1);
|
||||||
|
expect((db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('promo.card.2.title') as { value: string }).value)
|
||||||
|
.toBe('Small Group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i testi riscritti a mano corrispondono a quelli del seed', () => {
|
||||||
|
const seed = new Map(contentSeed.map((e) => [e.tag, e.value]));
|
||||||
|
for (const [tag, valore] of Object.entries(TESTI)) expect(seed.get(tag)).toBe(valore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viste di /training', () => {
|
||||||
|
it('senza vista si vedono tutti i training', () => {
|
||||||
|
expect(trainingDaMostrare()?.map((t) => t.id)).toEqual(trainings.map((t) => t.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('One to One porta a One to One, Personalized Training Group e Rehab', () => {
|
||||||
|
expect(trainingDaMostrare('one-to-one')?.map((t) => t.id)).toEqual(['one-to-one', 'small-groups', 'rehab']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Class porta a Holistic e Reformer', () => {
|
||||||
|
expect(trainingDaMostrare('class')?.map((t) => t.id)).toEqual(['holistic-class', 'reformer-class']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un filtro sbagliato che mostra tutto farebbe passare per buono un indirizzo rotto.
|
||||||
|
it('una vista inventata è 404, non l\'elenco intero', () => {
|
||||||
|
expect(trainingDaMostrare('inesistente')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ogni vista nomina solo training che esistono', () => {
|
||||||
|
for (const v of Object.values(VISTE_TRAINING)) {
|
||||||
|
for (const id of v.blocchi) expect(trainings.some((t) => t.id === id)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instradamento delle card di /services', () => {
|
||||||
|
const href = (slug: string) => { const s = getService(slug)!; return s.href ?? `/services/${s.slug}`; };
|
||||||
|
|
||||||
|
it('One to One e Class portano alle viste di training', () => {
|
||||||
|
expect(href('one-to-one')).toBe('/training/one-to-one');
|
||||||
|
expect(href('performance-class')).toBe('/training/class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Check-up porta alla propria scheda', () => {
|
||||||
|
expect(href('profilazione')).toBe('/services/profilazione');
|
||||||
|
});
|
||||||
|
|
||||||
|
// La Performance Class non si eroga più: la card resta, la scheda che la spiegava no.
|
||||||
|
it('la Performance Class non ha più una scheda ma resta in griglia', () => {
|
||||||
|
expect(getService('performance-class')?.page).toBe(false);
|
||||||
|
expect(getService('performance-class')?.grid).not.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nessun link del sito manda a una scheda che non esiste', () => {
|
||||||
|
const schede = new Set(services.filter((s) => s.page !== false).map((s) => `/services/${s.slug}`));
|
||||||
|
const interni = [...site.footerServices, ...site.footerTraining, ...site.navigation]
|
||||||
|
.map((v) => v.href)
|
||||||
|
.filter((h) => h.startsWith('/services/'));
|
||||||
|
for (const h of interni) expect(schede.has(h)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Check-up', () => {
|
||||||
|
const checkup = getService('profilazione')!;
|
||||||
|
|
||||||
|
it('il trafiletto della card racconta Base e Avanzato', () => {
|
||||||
|
expect(checkup.title).toBe('Check-up');
|
||||||
|
expect(checkup.subtitle).toBe('Base e avanzato');
|
||||||
|
expect(checkup.excerpt).toContain('Check-up Base');
|
||||||
|
expect(checkup.excerpt).toContain('Check-up Avanzato');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la scheda porta la sezione avanzata con i suoi cinque punti', () => {
|
||||||
|
expect(checkup.extra?.title).toBe('Check-up Avanzato');
|
||||||
|
expect(checkup.extra?.body).toContain('Wellness Tower');
|
||||||
|
expect(checkup.extra?.items).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tolta il 19/08 su richiesta del cliente: restava in fondo alla scheda e diceva cose che
|
||||||
|
// la sezione Avanzato racconta meglio.
|
||||||
|
it('la domanda «Che cosa comprende?» non c\'è più', () => {
|
||||||
|
expect(checkup.faq).toHaveLength(1);
|
||||||
|
expect(checkup.faq.map((f) => f.q)).not.toContain('Che cosa comprende?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i testi della sezione avanzata sono modificabili dal pannello', () => {
|
||||||
|
const tags = new Set(contentSeed.map((e) => e.tag));
|
||||||
|
expect(tags.has('service.profilazione.extra.title')).toBe(true);
|
||||||
|
expect(tags.has('service.profilazione.extra.body')).toBe(true);
|
||||||
|
for (let i = 1; i <= 5; i++) expect(tags.has(`service.profilazione.extra.item.${i}`)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calendario delle class', () => {
|
||||||
|
// Il calendario si apre da due punti diversi: le schede delle class che si fanno, e la
|
||||||
|
// card «Class» della griglia servizi, che ha slug `performance-class`.
|
||||||
|
it('le schede sono Holistic e Reformer, la Performance Class è fuori', () => {
|
||||||
|
expect(PAGINE_CON_CALENDARIO).toEqual(['holistic-class', 'reformer-class']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in griglia il bottone sta sulla card Class', () => {
|
||||||
|
expect(CARD_CON_CALENDARIO).toEqual(['performance-class']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ogni slug nominato esiste davvero', () => {
|
||||||
|
for (const slug of [...PAGINE_CON_CALENDARIO, ...CARD_CON_CALENDARIO]) expect(getService(slug)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('promo ed eventi', () => {
|
||||||
|
it('i testi della pagina di scelta e degli eventi sono nel seed', () => {
|
||||||
|
const tags = new Set(contentSeed.map((e) => e.tag));
|
||||||
|
for (const tag of ['promo-eventi.hero.title', 'promo-eventi.promo.label', 'promo-eventi.eventi.label',
|
||||||
|
'eventi.hero.title', 'eventi.inaugurazione.title', 'eventi.inaugurazione.body']) {
|
||||||
|
expect(tags.has(tag)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// I tag `eventi.*` restano nel seed — in produzione le righe esistono già — ma dal
|
||||||
|
// commit della landing dell'In-Sanity Hour non alimentano piu' nessuna pagina: la data
|
||||||
|
// vive nel suo tag, non nel titolo, da quando i due sono stati separati.
|
||||||
|
it('la data dell\'inaugurazione sta nel tag della data', () => {
|
||||||
|
expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.date')!.value).toContain('19 settembre 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
// La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti.
|
||||||
|
it('il menu punta alla pagina di scelta', () => {
|
||||||
|
expect(site.navigation.some((v) => v.href === '/promo-eventi')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createDb } from '../src/lib/db';
|
||||||
|
import {
|
||||||
|
createPost, trashPost, restorePost, deletePost, getPostById, listAllPosts, listByAuthor,
|
||||||
|
listTrashed, listPublished, listRecent, listArchiveMonths, getPublishedBySlug,
|
||||||
|
countPublishedByCategory,
|
||||||
|
} from '../src/lib/posts';
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
let id: number;
|
||||||
|
|
||||||
|
const articolo = (slug: string, authorId: number | null = 1) => ({
|
||||||
|
title: `Titolo ${slug}`, slug, category: 'training', excerpt: 'x',
|
||||||
|
body_html: '<p>x</p>', draft: false, author_id: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createDb(':memory:');
|
||||||
|
id = createPost(db, articolo('uno'));
|
||||||
|
createPost(db, articolo('due', 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cestino', () => {
|
||||||
|
it('cestinare non cancella: l\'articolo resta leggibile per id', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
const p = getPostById(db, id);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
expect(p!.deleted_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lo toglie da tutti gli elenchi del pannello', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(listAllPosts(db).map((p) => p.slug)).toEqual(['due']);
|
||||||
|
expect(listByAuthor(db, 1)).toEqual([]);
|
||||||
|
expect(listTrashed(db).map((p) => p.slug)).toEqual(['uno']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lo toglie dal sito pubblico: elenco, recenti, archivio e pagina singola', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(listPublished(db).items.map((p) => p.slug)).toEqual(['due']);
|
||||||
|
expect(listPublished(db).total).toBe(1);
|
||||||
|
expect(listPublished(db, { category: 'training' }).total).toBe(1);
|
||||||
|
expect(listRecent(db).map((p) => p.slug)).toEqual(['due']);
|
||||||
|
expect(listArchiveMonths(db).reduce((n, m) => n + m.count, 0)).toBe(1);
|
||||||
|
expect(getPublishedBySlug(db, 'uno')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il cestino di un autore contiene solo i suoi articoli', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(listTrashed(db, 1).map((p) => p.slug)).toEqual(['uno']);
|
||||||
|
expect(listTrashed(db, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il ripristino lo rimette dov\'era', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
restorePost(db, id);
|
||||||
|
expect(getPostById(db, id)!.deleted_at).toBeNull();
|
||||||
|
expect(listTrashed(db)).toEqual([]);
|
||||||
|
expect(listAllPosts(db).map((p) => p.slug)).toContain('uno');
|
||||||
|
expect(getPublishedBySlug(db, 'uno')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i conteggi per categoria escludono cestino e bozze', () => {
|
||||||
|
expect(countPublishedByCategory(db)).toEqual({ training: 2 });
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(countPublishedByCategory(db)).toEqual({ training: 1 });
|
||||||
|
restorePost(db, id);
|
||||||
|
expect(countPublishedByCategory(db)).toEqual({ training: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la cancellazione definitiva rimuove davvero la riga', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
deletePost(db, id);
|
||||||
|
expect(getPostById(db, id)).toBeUndefined();
|
||||||
|
expect(listTrashed(db)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { edizioni, edizioneAttiva, tagEdizione, type Edizione } from '../src/data/promo-edizioni';
|
||||||
|
import { seedByTag } from '../src/data/content-seed';
|
||||||
|
|
||||||
|
const d = (iso: string) => new Date(iso);
|
||||||
|
|
||||||
|
describe('edizioneAttiva', () => {
|
||||||
|
it('sceglie l\'edizione la cui finestra contiene l\'istante', () => {
|
||||||
|
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
|
||||||
|
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
|
||||||
|
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))?.id).toBe('settembre-2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fuori da ogni finestra non c\'è edizione attiva', () => {
|
||||||
|
expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
|
||||||
|
expect(edizioneAttiva(d('2026-09-25T12:00:00+02:00'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
|
||||||
|
// italiana. Il server gira in UTC, quindi un confronto fatto sull'orologio locale della
|
||||||
|
// macchina anticiperebbe (o ritarderebbe) lo scambio di due ore.
|
||||||
|
it('scambia le edizioni al secondo giusto, in ora italiana', () => {
|
||||||
|
expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026');
|
||||||
|
expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura');
|
||||||
|
expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
|
||||||
|
expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))?.id).toBe('settembre-2026');
|
||||||
|
expect(edizioneAttiva(d('2026-09-19T23:59:59+02:00'))?.id).toBe('settembre-2026');
|
||||||
|
expect(edizioneAttiva(d('2026-09-20T00:00:00+02:00'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('l\'ordine dell\'elenco non cambia il risultato', () => {
|
||||||
|
const rovescio = [...edizioni].reverse();
|
||||||
|
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026');
|
||||||
|
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
|
||||||
|
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'), rovescio)?.id).toBe('settembre-2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coerenza delle edizioni configurate', () => {
|
||||||
|
const ordinate = [...edizioni].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
|
||||||
|
|
||||||
|
it('ogni finestra è valida e ha un verso', () => {
|
||||||
|
for (const e of edizioni) {
|
||||||
|
expect(Number.isNaN(Date.parse(e.from)), `${e.id}: from illeggibile`).toBe(false);
|
||||||
|
expect(Number.isNaN(Date.parse(e.to)), `${e.id}: to illeggibile`).toBe(false);
|
||||||
|
expect(Date.parse(e.to), `${e.id}: finisce prima di cominciare`).toBeGreaterThan(Date.parse(e.from));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Due promozioni insieme sul sito è il difetto peggiore: prezzi diversi per la stessa cosa,
|
||||||
|
// e nessuno sa quale vale. Nessun buco, invece, evita il giorno in cui la pagina non offre
|
||||||
|
// niente senza che nessuno se ne accorga: se un buco è voluto va scritto qui, non subìto.
|
||||||
|
it('le finestre non si sovrappongono e non lasciano buchi', () => {
|
||||||
|
for (let i = 1; i < ordinate.length; i++) {
|
||||||
|
const prima = ordinate[i - 1];
|
||||||
|
const dopo = ordinate[i];
|
||||||
|
const stacco = Date.parse(dopo.from) - Date.parse(prima.to);
|
||||||
|
expect(stacco, `${prima.id} e ${dopo.id} si sovrappongono`).toBeGreaterThan(0);
|
||||||
|
expect(stacco, `fra ${prima.id} e ${dopo.id} c'è un buco`).toBeLessThanOrEqual(1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il countdown è la promessa fatta al visitatore. Se dice una data e lo scambio ne usa
|
||||||
|
// un'altra, il sito si contraddice da solo: o cambia mentre il contatore corre ancora, o
|
||||||
|
// resta lì a zero. Sono lo stesso numero e devono restare lo stesso numero.
|
||||||
|
it('il countdown scade quando finisce l\'edizione', () => {
|
||||||
|
for (const e of edizioni) {
|
||||||
|
const deadline = seedByTag.get(tagEdizione(e, 'countdown.deadline'))?.value;
|
||||||
|
expect(deadline, `${e.id}: manca il tag della deadline`).toBeTruthy();
|
||||||
|
expect(Date.parse(deadline!), `${e.id}: countdown e fine edizione non coincidono`)
|
||||||
|
.toBe(Date.parse(e.to));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('i testi di ogni edizione esistono', () => {
|
||||||
|
// Un tag che manca non rompe la pagina: `resolve` restituisce stringa vuota e logga un warn
|
||||||
|
// che nessuno legge. In vetrina diventa una card senza prezzo, o un titolo sparito. Meglio
|
||||||
|
// scoprirlo qui che dal cliente.
|
||||||
|
const richiesti = (e: Edizione): string[] => {
|
||||||
|
const base = ['hero.badge', 'hero.title1', 'hero.accent', 'hero.title2', 'hero.desc', 'hero.cta',
|
||||||
|
'buy-label', 'validity-note', 'faq.title',
|
||||||
|
'countdown.label', 'countdown.deadline',
|
||||||
|
'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds'];
|
||||||
|
if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
|
||||||
|
for (const c of e.cards) {
|
||||||
|
base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`);
|
||||||
|
if (c.priceOld) base.push(`card.${c.id}.price-old`);
|
||||||
|
if (c.featured) base.push(`card.${c.id}.badge`);
|
||||||
|
if (c.unit) base.push(`card.${c.id}.unit`);
|
||||||
|
if (c.question) base.push(`card.${c.id}.question`);
|
||||||
|
if (c.desc) base.push(`card.${c.id}.desc`);
|
||||||
|
if (c.note) base.push(`card.${c.id}.note-label`, `card.${c.id}.note`);
|
||||||
|
for (let i = 1; i <= c.features; i++) base.push(`card.${c.id}.feature.${i}`);
|
||||||
|
}
|
||||||
|
for (const n of e.faq) base.push(`faq.${n}.q`, `faq.${n}.a`);
|
||||||
|
return base.map((s) => tagEdizione(e, s));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const e of edizioni) {
|
||||||
|
it(`${e.id}: tutti i tag sono nel seed`, () => {
|
||||||
|
const mancanti = richiesti(e).filter((tag) => !seedByTag.has(tag));
|
||||||
|
expect(mancanti, `tag mancanti: ${mancanti.join(', ')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lo stato "nessuna promozione" ha i suoi testi', () => {
|
||||||
|
for (const tag of ['promo.chiusa.title', 'promo.chiusa.desc', 'promo.chiusa.cta']) {
|
||||||
|
expect(seedByTag.has(tag), `manca ${tag}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Cosa resta vero in questo sito dopo la separazione del 03/09/2026, quando Campus,
|
||||||
|
// Stress Index e Longevity sono passati all'app delle aree riservate (repo InsanityLab-platforms) con i loro utenti.
|
||||||
|
// Eredita le due asserzioni ancora valide di tests/longevity/ruoli.test.ts (emigrato) e
|
||||||
|
// aggiunge la guardia sul ciclo di redirect, che è nata proprio da questa separazione.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ROLES, isRole, landingFor, canAccessAdminPath } from '../src/lib/auth';
|
||||||
|
|
||||||
|
describe('ROLES — restano solo i ruoli redazionali', () => {
|
||||||
|
it('i tre ruoli del sito, e nessuno di quelli emigrati', () => {
|
||||||
|
expect([...ROLES].sort()).toEqual(['admin', 'superuser', 'user']);
|
||||||
|
for (const emigrato of ['piattaforme', 'cliente', 'trainer']) expect(isRole(emigrato)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isRole accetta esattamente i ruoli di ROLES', () => {
|
||||||
|
for (const r of ROLES) expect(isRole(r)).toBe(true);
|
||||||
|
for (const finto of ['campus', 'Admin', '', 'user ']) expect(isRole(finto)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la tendina di admin/users.astro pesca da ROLES, non da una lista propria', () => {
|
||||||
|
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8');
|
||||||
|
expect(src).toContain('ROLES');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le descrizioni dei ruoli in users/new.astro non nominano ruoli che non esistono più', () => {
|
||||||
|
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users/new.astro'), 'utf8');
|
||||||
|
for (const emigrato of ['piattaforme', 'cliente', 'trainer']) expect(src).not.toContain(`${emigrato}:`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('⚠️ il ciclo di redirect che la separazione ha reso possibile', () => {
|
||||||
|
it('un ruolo emigrato rimasto in `users` NON può accedere a /admin, ma è lì che atterrerebbe', () => {
|
||||||
|
// Questa coppia è il ciclo: landingFor lo manda su /admin, canAccessAdminPath glielo
|
||||||
|
// nega, il middleware lo rimanda su landingFor. È la ragione della guardia nel login.
|
||||||
|
expect(landingFor('piattaforme')).toBe('/admin');
|
||||||
|
expect(canAccessAdminPath('piattaforme', '/admin')).toBe(false);
|
||||||
|
expect(canAccessAdminPath('cliente', '/admin')).toBe(false);
|
||||||
|
expect(canAccessAdminPath('trainer', '/admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il login del pannello rifiuta questi account invece di aprire una sessione', () => {
|
||||||
|
const src = readFileSync(join(process.cwd(), 'src/pages/admin/login.astro'), 'utf8');
|
||||||
|
expect(src).toContain('isRole');
|
||||||
|
expect(src).toContain('logout(getDb(), token)');
|
||||||
|
expect(src).toMatch(/apps\.insanitylab\.it/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { safeNext } from '../src/lib/safe-next';
|
|
||||||
|
|
||||||
describe('safeNext', () => {
|
|
||||||
it('accetta path interni', () => {
|
|
||||||
expect(safeNext('/about')).toBe('/about');
|
|
||||||
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
|
|
||||||
});
|
|
||||||
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
|
|
||||||
expect(safeNext(null)).toBe('/');
|
|
||||||
expect(safeNext('')).toBe('/');
|
|
||||||
expect(safeNext('//evil.com')).toBe('/');
|
|
||||||
expect(safeNext('https://evil.com')).toBe('/');
|
|
||||||
expect(safeNext('/api/admin/posts')).toBe('/');
|
|
||||||
expect(safeNext('javascript:alert(1)')).toBe('/');
|
|
||||||
expect(safeNext('not-a-path')).toBe('/');
|
|
||||||
expect(safeNext('/\\evil.com')).toBe('/');
|
|
||||||
expect(safeNext('/\\/evil.com')).toBe('/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
|
"include": [
|
||||||
"exclude": ["dist"]
|
".astro/types.d.ts",
|
||||||
|
"src/**/*",
|
||||||
|
"tests/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,14 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: { include: ['tests/**/*.test.ts'] },
|
test: { include: ['tests/**/*.test.ts'] },
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
// src/middleware.ts importa da 'astro:middleware', un modulo virtuale che Astro
|
||||||
|
// risolve solo dentro la propria pipeline vite (dev/build), non sotto vitest puro.
|
||||||
|
// Stesso bersaglio che Astro stesso usa (node_modules/astro/dist/core/create-vite.js):
|
||||||
|
// defineMiddleware è identità (fn => fn), sequence è reale. Senza questo alias il
|
||||||
|
// middleware non è caricabile in isolamento e nessun test può importarlo davvero.
|
||||||
|
{ find: 'astro:middleware', replacement: 'astro/virtual-modules/middleware.js' },
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||