Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b71cbb49e | |||
| f914d0699a | |||
| f5fac89c1b | |||
| 665c598462 | |||
| 4103376eb1 | |||
| b75e12de86 | |||
| 718ca499c4 | |||
| 4f031cf571 | |||
| 6ba8794770 | |||
| 0ea1cb5e77 | |||
| 7f96b95e04 | |||
| 13d73f90a8 | |||
| 7fd3961469 | |||
| 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 |
@@ -2,7 +2,10 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
.env
|
.env
|
||||||
|
.env.bak*
|
||||||
data/*.db*
|
data/*.db*
|
||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
assets-src/
|
assets-src/
|
||||||
|
__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,9 +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 quattro ruoli (`admin`, `superuser`, `user`, `campus`).
|
- **Cestino degli articoli**: l'eliminazione sposta in `/admin/cestino`, da dove si ripristina o si cancella in via definitiva.
|
||||||
- **Campus**: sezione riservata con le guide di studio del corso Biohacking Campus, accessibile solo dopo il login e con navigazione autonoma.
|
- **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)
|
||||||
@@ -54,8 +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\|campus]` |
|
| `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 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
|
||||||
|
|
||||||
@@ -69,43 +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
|
||||||
|
|
||||||
Quattro 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`), Campus |
|
| `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 |
|
||||||
| `campus` | Solo la sezione riservata `/campus`; nessun accesso al pannello |
|
| `piattaforme` | Solo le sezioni riservate (`/piattaforme`, `/campus`); nessun accesso al pannello |
|
||||||
|
|
||||||
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, il Campus per il ruolo omonimo. 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.
|
||||||
|
|
||||||
## Campus (sezione riservata)
|
> 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.
|
||||||
|
|
||||||
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 alla home — ma usano i font, i colori e il CSS globale di InsanityLab.
|
## 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`.
|
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.
|
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, /campus)
|
│ ├── 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, Admin, Campus
|
│ ├── 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)
|
||||||
├── campus-content/ # Contenuti del Campus generati da sync-campus
|
├── campus-content/ # Contenuti del Campus generati da sync-campus
|
||||||
├── scripts/ # create-user, sync-campus, smoke test
|
├── 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
|
||||||
@@ -137,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
|
||||||
|
|
||||||
@@ -162,6 +199,10 @@ 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.
|
||||||
|
|||||||
@@ -23,7 +23,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.
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"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": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
@@ -32,6 +33,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",
|
||||||
"marked": "^18.0.7",
|
"marked": "^18.0.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -1964,6 +1966,30 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oslojs/encoding": {
|
"node_modules/@oslojs/encoding": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||||
@@ -2387,6 +2413,15 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.27.1",
|
"version": "3.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
|
||||||
@@ -2979,6 +3014,16 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pdfkit": {
|
||||||
|
"version": "0.17.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||||
|
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
@@ -3583,6 +3628,24 @@
|
|||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.7",
|
"version": "4.28.7",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||||
@@ -3751,6 +3814,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -4163,6 +4235,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||||
@@ -4449,7 +4527,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-string-truncated-width": {
|
"node_modules/fast-string-truncated-width": {
|
||||||
@@ -4534,6 +4611,23 @@
|
|||||||
"fontkitten": "^1.0.2"
|
"fontkitten": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fontkitten": {
|
"node_modules/fontkitten": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz",
|
||||||
@@ -4889,6 +4983,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-md5": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5222,6 +5322,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/linkifyjs": {
|
"node_modules/linkifyjs": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
@@ -5692,6 +5811,12 @@
|
|||||||
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
|
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parse-srcset": {
|
"node_modules/parse-srcset": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
@@ -5712,6 +5837,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfkit": {
|
||||||
|
"version": "0.19.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||||
|
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.6.0",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/piccolore": {
|
"node_modules/piccolore": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
||||||
@@ -5736,6 +5875,14 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"browserify-zlib": "^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
@@ -6216,6 +6363,12 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/retext-smartypants": {
|
"node_modules/retext-smartypants": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
|
||||||
@@ -6782,8 +6935,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD",
|
"license": "0BSD"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
@@ -6853,6 +7005,32 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"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": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
@@ -38,6 +39,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",
|
||||||
"marked": "^18.0.7",
|
"marked": "^18.0.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 242 KiB |
@@ -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,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>
|
||||||
@@ -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' }));
|
||||||
|
}
|
||||||
@@ -165,7 +165,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'),
|
||||||
@@ -222,7 +222,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', []),
|
||||||
@@ -231,7 +231,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ù →', []),
|
||||||
@@ -247,6 +247,13 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
|
T(`service.${p.slug}.faq.${i + 1}.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`),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -277,8 +284,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 ---
|
||||||
@@ -293,6 +301,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', []),
|
||||||
@@ -327,10 +358,10 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
T('promo.card.1.feature.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', '125 €', []),
|
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', []),
|
||||||
@@ -369,12 +400,16 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
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', []),
|
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?'),
|
||||||
@@ -382,6 +417,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;
|
||||||
|
}
|
||||||
@@ -13,12 +13,24 @@ 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",
|
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.",
|
||||||
@@ -33,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." },
|
||||||
],
|
],
|
||||||
@@ -47,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.",
|
||||||
@@ -61,7 +79,7 @@ export const services: Service[] = [
|
|||||||
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
|
{ q: "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",
|
||||||
@@ -81,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).
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ export const site = {
|
|||||||
],
|
],
|
||||||
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -247,6 +247,27 @@ export function createDb(path?: string): Database.Database {
|
|||||||
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
|
// 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();
|
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.
|
// Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti.
|
||||||
const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
|
const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
|
||||||
if (!colonnePost.some((c) => c.name === 'deleted_at')) {
|
if (!colonnePost.some((c) => c.name === 'deleted_at')) {
|
||||||
|
|||||||
@@ -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,260 +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');
|
|
||||||
// Scadenza della promo (tag editabile dal pannello, formato 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 e non con quello del render (la pagina è SSR ma può essere servita da cache).
|
|
||||||
const countdownDeadline = t('promo.countdown.deadline');
|
|
||||||
---
|
|
||||||
<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="countdown" data-deadline={countdownDeadline} hidden>
|
|
||||||
<T tag="promo.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="promo.countdown.unit.days" as="span" class="cd-unit" /></div>
|
|
||||||
<div class="cd-box"><span class="cd-num" data-cd="hours">–</span><T tag="promo.countdown.unit.hours" as="span" class="cd-unit" /></div>
|
|
||||||
<div class="cd-box"><span class="cd-num" data-cd="minutes">–</span><T tag="promo.countdown.unit.minutes" as="span" class="cd-unit" /></div>
|
|
||||||
<div class="cd-box"><span class="cd-num" data-cd="seconds">–</span><T tag="promo.countdown.unit.seconds" as="span" class="cd-unit" /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
// 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; }
|
|
||||||
|
|
||||||
/* 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 .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>
|
|
||||||
@@ -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>
|
||||||
@@ -7,13 +7,21 @@ 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';
|
||||||
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);
|
||||||
@@ -38,6 +46,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 +76,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__other prog__other--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 +122,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 +140,9 @@ 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); }
|
||||||
|
/* La voce del calendario apre un popup, quindi è un bottone e non un link: qui gli si
|
||||||
|
toglie l'aspetto da bottone perché nella colonna sia indistinguibile dalle altre voci. */
|
||||||
|
.prog__other--calendario { background: none; border: 0; padding: 0; cursor: pointer; width: 100%; text-align: left; }
|
||||||
.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,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,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||