Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a30bc08b9 | |||
| bcc965655d | |||
| 8d593dce90 | |||
| 47a93049bb | |||
| 5ff47711cd | |||
| f48518d8fd | |||
| 30b509436e | |||
| 8f47cf9e94 | |||
| 9f6cf29708 | |||
| 3183bd795d | |||
| 1b9a3c1343 | |||
| 3b8983dca5 | |||
| 3b4d38e087 | |||
| 02b5180227 | |||
| c722680590 | |||
| 172cef236b | |||
| 1aa82f5332 | |||
| d79aa912b1 | |||
| 86d608a929 | |||
| 708410bbf1 | |||
| 53af0ca00e | |||
| 6594f6da53 | |||
| c6de02be83 | |||
| 11fc2b3e54 | |||
| 5b34ee1697 | |||
| 2468ce95f7 | |||
| 9966ec3616 | |||
| 140146fce1 | |||
| 7211d74d3a | |||
| e2bbc9074f | |||
| be77285613 | |||
| c36ad4cb2d | |||
| 0f2b2ba014 | |||
| 2bf6b3d4de | |||
| 22d72be74d | |||
| 842371604b | |||
| 71cd1626e0 | |||
| 6b85872515 | |||
| 5ce10da9bd | |||
| c3267c9be2 | |||
| 6d1e2607ee | |||
| db92fc9c02 | |||
| e87a464090 | |||
| 5cea895d51 | |||
| d4cf359805 | |||
| d0fa75a3d3 | |||
| 6c225281e0 | |||
| 8ceea9b16f | |||
| c975dcac12 | |||
| 8495d9edd4 | |||
| 56ca6eca14 | |||
| 608b1b68d2 | |||
| be52781499 | |||
| bf19cba4d3 | |||
| eda4d73aac | |||
| b5c0c107bc | |||
| 685ef4a36e | |||
| 3551fae132 | |||
| bf38fc865c | |||
| 31354c4457 | |||
| b84463c9df | |||
| d5b19f2c39 | |||
| 34fd3d2e18 | |||
| 3f01721a99 | |||
| f4c350ad64 | |||
| b079ded266 | |||
| b22cc40b03 | |||
| 6f51aec4c6 | |||
| 5f1d9f3740 | |||
| 0f94b5017c | |||
| 274a6c7600 | |||
| 77041f9d5a | |||
| c7d9d4ba5d | |||
| 83237feb65 | |||
| b83d62c10c | |||
| 875b369939 | |||
| 98c42aae54 | |||
| 7d2f0b7048 | |||
| ecdf12ee0e | |||
| 2d5ff1816d | |||
| 6cc1cf5f44 |
@@ -1,8 +1,17 @@
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=user
|
||||
SMTP_PASS=pass
|
||||
# SMTP Aruba — il dominio insanitylab.it invia la posta via Aruba (SPF: include:aruba.it)
|
||||
# Server posta in uscita autenticato: smtps.aruba.it:465 (SSL). Verificato funzionante.
|
||||
SMTP_HOST=smtps.aruba.it
|
||||
SMTP_PORT=465
|
||||
# Casella reale del dominio usata per l'invio autenticato (con la sua password Aruba)
|
||||
SMTP_USER=postmaster@insanitylab.it
|
||||
SMTP_PASS=la-password-della-casella
|
||||
# Aruba impone che il mittente sia la casella autenticata: uguale a SMTP_USER
|
||||
CONTACT_FROM=postmaster@insanitylab.it
|
||||
# Dove arrivano i messaggi del form
|
||||
CONTACT_TO=info@insanitylab.it
|
||||
CONTACT_FROM=sito@insanitylab.it
|
||||
DB_PATH=data/insanitylab.db
|
||||
UPLOADS_DIR=uploads
|
||||
|
||||
# Google Tag Manager: ID container preso da tagmanager.google.com (accanto al nome container).
|
||||
# Formato GTM-XXXXXXX. Lascia vuoto per non caricare GTM (es. in locale/staging).
|
||||
GTM_ID=GTM-XXXXXXX
|
||||
|
||||
@@ -1,137 +1,154 @@
|
||||
# InsanityLab Website
|
||||
|
||||
Sito vetrina per InsanityLab con blog integrato e form di contatti.
|
||||
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**.
|
||||
|
||||
## Descrizione
|
||||
|
||||
Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 7** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages.
|
||||
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.
|
||||
|
||||
## Stack Tecnologico
|
||||
## Stack tecnologico
|
||||
|
||||
- **Framework**: Astro 7
|
||||
- **Runtime**: Node.js (adapter standalone)
|
||||
- **Database**: SQLite (better-sqlite3)
|
||||
- **Autenticazione**: bcryptjs
|
||||
- **Email**: Nodemailer
|
||||
- **Markup Editor**: TipTap (rich text)
|
||||
- **Font System**: @fontsource (Montserrat, Open Sans)
|
||||
- **Sitemap**: @astrojs/sitemap
|
||||
- **Testing**: Vitest
|
||||
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
|
||||
- **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone)
|
||||
- **Database**: SQLite via `better-sqlite3`
|
||||
- **Autenticazione**: sessioni con password hashate (`bcryptjs`)
|
||||
- **Email**: Nodemailer (SMTP)
|
||||
- **Editor articoli**: TipTap (rich text)
|
||||
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
|
||||
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
|
||||
- **Test**: Vitest
|
||||
- **Linguaggio**: TypeScript
|
||||
- **Deploy**: Docker (multi‑stage, `node:22-slim`) + Traefik su VPS
|
||||
|
||||
## Installazione
|
||||
## Funzionalità principali
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`).
|
||||
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
|
||||
|
||||
## Installazione (sviluppo)
|
||||
|
||||
### Prerequisiti
|
||||
|
||||
- Node.js 18+ e npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Installa dipendenze
|
||||
npm install
|
||||
|
||||
# Copia il template di configurazione env
|
||||
cp .env.example .env
|
||||
|
||||
# Crea il primo utente amministratore (dopo aver configurato il DB)
|
||||
npm run create-user -- admin tuapassword
|
||||
cp .env.example .env # poi compila i valori reali
|
||||
npm run create-user -- admin <password> admin
|
||||
npm run dev # http://localhost:4321
|
||||
```
|
||||
|
||||
Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente.
|
||||
Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato automaticamente con i contenuti di default (seed) e le migrazioni idempotenti.
|
||||
|
||||
## Comandi Disponibili
|
||||
## Comandi
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Avvia il server di sviluppo (localhost:4321) |
|
||||
| `npm run build` | Compila il progetto per production |
|
||||
| `npm run preview` | Visualizza l'anteprima della build (locale) |
|
||||
| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) |
|
||||
| `npm run test` | Esegui i test con Vitest |
|
||||
| `npm run create-user` | Crea un nuovo utente (admin o redattore) |
|
||||
| `npm run dev` | Server di sviluppo (localhost:4321) |
|
||||
| `npm run build` | Build di produzione |
|
||||
| `npm run preview` | Anteprima locale della build |
|
||||
| `npm run start` | Server di produzione (`node --env-file=.env`) |
|
||||
| `npm run test` | Test con Vitest |
|
||||
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user]` |
|
||||
|
||||
## Struttura Progetto
|
||||
## Gestione contenuti
|
||||
|
||||
I testi e le immagini della vetrina sono **blocchi taggati** salvati nella tabella `content_blocks`. Ogni blocco ha un tag stabile (es. `home.hero.title-line-1`), un valore, eventuali stili predefiniti, un tipo (`text` / `html` / `image`) e un **GUID** univoco.
|
||||
|
||||
- **Pannello** `/admin/content`: i blocchi sono raggruppati per pagina, con ricerca per tag o contenuto, upload delle immagini e ripristino all'originale.
|
||||
- **Modifica inline**: attivando "Mostra tag" dal menu utente, ogni blocco mostra un badge (nome tag + matita) che apre una finestra di modifica direttamente in pagina.
|
||||
- **Componenti** `<T>` e `<TImg>`: rendono i blocchi con fallback automatico al seed quando un tag non è ancora nel database.
|
||||
|
||||
Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui contenuti già esistenti (rinomine, testi aggiornati) si applicano tramite **migrazioni idempotenti** in `src/lib/db.ts`, così da non sovrascrivere le modifiche fatte dal pannello.
|
||||
|
||||
> 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)`).
|
||||
|
||||
## Ruoli e accessi
|
||||
|
||||
Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
|
||||
|
||||
| Ruolo | Può fare |
|
||||
|-------|----------|
|
||||
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) |
|
||||
| `superuser` | Pannello contenuti + blog |
|
||||
| `user` | Solo i **propri** articoli del blog |
|
||||
|
||||
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
insanitylab-website/
|
||||
├── src/
|
||||
│ ├── pages/ # Pagine Astro (routing automatico)
|
||||
│ ├── components/ # Componenti Astro riutilizzabili
|
||||
│ ├── layouts/ # Layout master
|
||||
│ ├── utils/ # Funzioni di utilità
|
||||
│ ├── env.d.ts # Type definitions globali
|
||||
│ └── ...
|
||||
├── data/ # Database SQLite e dati persistenti
|
||||
│ └── insanitylab.db # (generato al primo avvio)
|
||||
├── uploads/ # Directory per upload immagini e file
|
||||
├── dist/ # Output build (server)
|
||||
├── tests/ # Test suite
|
||||
├── astro.config.mjs # Configurazione Astro
|
||||
├── tsconfig.json # Configurazione TypeScript
|
||||
├── vitest.config.ts # Configurazione test framework
|
||||
├── .env.example # Template variabili di ambiente
|
||||
├── .env # Variabili di ambiente (non versionato)
|
||||
├── package.json # Dipendenze e script npm
|
||||
└── README.md # Questo file
|
||||
│ ├── pages/ # Route Astro (vetrina, /admin, /api)
|
||||
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
|
||||
│ ├── layouts/ # Layout Base
|
||||
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
|
||||
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
|
||||
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
||||
│ └── styles/ # global.css
|
||||
├── data/ # SQLite (volume in produzione)
|
||||
├── uploads/ # Upload immagini (volume in produzione)
|
||||
├── scripts/ # create-user, smoke test
|
||||
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
|
||||
├── compose.yaml # Stack Docker + label Traefik
|
||||
├── Dockerfile # Build multi-stage
|
||||
├── .env.example # Template variabili d'ambiente
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Configurazione Ambiente
|
||||
## Variabili d'ambiente
|
||||
|
||||
Il file `.env` richiede le seguenti variabili:
|
||||
Il file `.env` (non versionato) richiede:
|
||||
|
||||
- `SMTP_HOST`: Host SMTP per invio email
|
||||
- `SMTP_PORT`: Porta SMTP (solitamente 587)
|
||||
- `SMTP_USER`: Utente SMTP
|
||||
- `SMTP_PASS`: Password SMTP
|
||||
- `CONTACT_TO`: Email di destinazione contatti
|
||||
- `CONTACT_FROM`: Email mittente
|
||||
- `DB_PATH`: Percorso database SQLite
|
||||
- `UPLOADS_DIR`: Cartella upload file
|
||||
| Variabile | Descrizione |
|
||||
|-----------|-------------|
|
||||
| `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) |
|
||||
| `SMTP_PORT` | Porta SMTP (`465` con SSL) |
|
||||
| `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) |
|
||||
| `SMTP_PASS` | Password della casella |
|
||||
| `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) |
|
||||
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
||||
| `DB_PATH` | Percorso del database SQLite |
|
||||
| `UPLOADS_DIR` | Cartella degli upload |
|
||||
|
||||
## Development Workflow
|
||||
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
|
||||
|
||||
1. **Avvia il server locale**: `npm run dev`
|
||||
2. **Modifica file** in `src/` (hot reload automatico)
|
||||
3. **Test**: `npm run test`
|
||||
4. **Build**: `npm run build` (verifica errori di build)
|
||||
5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione)
|
||||
## Deploy in produzione
|
||||
|
||||
## Deployment
|
||||
Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/insanitylab/` (repo in `src/`, volumi `data/` e `uploads/`). Aggiornamento:
|
||||
|
||||
```bash
|
||||
# Build per production
|
||||
npm run build
|
||||
|
||||
# Opzionalmente, preview locale
|
||||
npm run preview
|
||||
|
||||
# In production, il server parte con:
|
||||
npm run start
|
||||
|
||||
# Variabili ambiente in production vanno impostate sull'hosting
|
||||
# (es. Hostinger VPS, Vercel, etc.)
|
||||
cd /opt/docker/insanitylab/src
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Riferimenti Tecnici
|
||||
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.
|
||||
|
||||
Per dettagli su architettura, schema database, componenti e specifiche vedi:
|
||||
- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
|
||||
- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md`
|
||||
### Dominio e DNS
|
||||
|
||||
## Problemi Comuni
|
||||
- Dominio ufficiale: **insanitylab.it** (canonico l'apex; `www` fa redirect 301 all'apex tramite Traefik).
|
||||
- I record **A** di `@` e `www` puntano al VPS; la **posta resta su Aruba** (MX, SPF, webmail non vengono toccati).
|
||||
- TLS Let's Encrypt automatico via Traefik (certresolver `mytlschallenge`).
|
||||
|
||||
### `better-sqlite3` fallisce su npm install
|
||||
> Attenzione dietro Traefik: `allowedDomains` in `astro.config.mjs` deve elencare i domini serviti, altrimenti Astro ignora `X-Forwarded-Proto/Host` e il controllo CSRF respinge i POST con 403.
|
||||
|
||||
Se `better-sqlite3` non compila, assicurati di avere build tools installati:
|
||||
- **Linux**: `sudo apt-get install build-essential python3`
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: Visual Studio Build Tools o MinGW
|
||||
## Problemi comuni
|
||||
|
||||
### Database locked
|
||||
**`better-sqlite3` non compila su `npm install`** — servono i build tools:
|
||||
- Linux: `sudo apt-get install build-essential python3`
|
||||
- macOS: `xcode-select --install`
|
||||
- Windows: Visual Studio Build Tools
|
||||
|
||||
Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`.
|
||||
**Modifiche allo `<style>` scoped di `[slug].astro` non compaiono in dev** — l'HMR di Astro 7 non sempre ricompila lo stile scoped delle route dinamiche: riavviare il dev server (`astro dev stop` + `npm run dev`).
|
||||
|
||||
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietario InsanityLab.
|
||||
Proprietà di InsanityLab.
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://insanitylab.tielogic.xyz',
|
||||
site: 'https://insanitylab.it',
|
||||
output: 'server',
|
||||
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
|
||||
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
|
||||
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
|
||||
// sottodominio tielogic.xyz redirigono all'apex a livello Traefik.
|
||||
security: {
|
||||
allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }],
|
||||
allowedDomains: [
|
||||
{ hostname: 'insanitylab.it', protocol: 'https' },
|
||||
{ hostname: 'www.insanitylab.it', protocol: 'https' },
|
||||
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
|
||||
],
|
||||
},
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [sitemap({ filter: (page) => !page.includes('/admin') })],
|
||||
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
|
||||
redirects: {
|
||||
'/programs': { status: 301, destination: '/services' },
|
||||
'/programs/[slug]': { status: 301, destination: '/services/[slug]' },
|
||||
// Modifiche 02: "Pilates Flow" → "Profilazione". "Rehab" esce dalla griglia Servizi e
|
||||
// diventa un riquadro Training, ma la pagina dettaglio resta su /services/rehab.
|
||||
'/services/pilates-flow': { status: 301, destination: '/services/profilazione' },
|
||||
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
|
||||
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,11 +16,22 @@ services:
|
||||
- /opt/docker/insanitylab/uploads:/app/uploads
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.tielogic.xyz`)
|
||||
# Router principale: solo il dominio ufficiale canonico (apex insanitylab.it)
|
||||
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.it`)
|
||||
- traefik.http.routers.insanitylab.tls=true
|
||||
- traefik.http.routers.insanitylab.entrypoints=websecure
|
||||
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
|
||||
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
|
||||
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
|
||||
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`)
|
||||
- traefik.http.routers.insanitylab-redir.tls=true
|
||||
- traefik.http.routers.insanitylab-redir.entrypoints=websecure
|
||||
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.insanitylab-redir.service=insanitylab
|
||||
- traefik.http.routers.insanitylab-redir.middlewares=insanitylab-tocanon
|
||||
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.regex=^https?://[^/]+/(.*)
|
||||
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.replacement=https://insanitylab.it/$${1}
|
||||
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.permanent=true
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Sito web InsanityLab — Design
|
||||
|
||||
**Data:** 2026-07-01
|
||||
**Dominio di destinazione:** insanitylab.tielogic.xyz (VPS Hostinger, porting in fase successiva)
|
||||
**Stato:** approvato dall'operatore durante la sessione di brainstorming
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Realizzare il sito vetrina di InsanityLab (centro di allenamento e benessere, Bologna) a partire dal design Figma fornito dal designer (`FigmaDoc/*.pdf`, `Web Insanity.fig`), che ristilizza il template WordPress "Prowess" (analizzato in `Dati/cassetta-me-analisi.md`) con una palette beige/marrone al posto del rosso originale. Il sito include un blog gestibile dal cliente tramite pannello protetto da login e un form contatti che invia email. La gestione degli acquisti online (pagina prezzi Performance Class) è rimandata a una fase successiva.
|
||||
|
||||
## Stack
|
||||
|
||||
| Livello | Scelta |
|
||||
|---|---|
|
||||
| Framework | Astro 5, output `server` con adapter Node |
|
||||
| Pagine vetrina | Prerenderizzate (`prerender = true`) |
|
||||
| Blog e admin | Server-rendered |
|
||||
| Stile | CSS custom (palette estratta dai PDF Figma), nessun framework CSS |
|
||||
| Font | Montserrat (titoli) + Open Sans (corpo), self-hosted |
|
||||
| JS client | Vanilla, minimo indispensabile (slider, tab, accordion, header sticky) |
|
||||
| Database | SQLite via `better-sqlite3`, file `data/insanitylab.db` |
|
||||
| Editor admin | WYSIWYG TipTap, con upload immagini nel corpo articolo |
|
||||
| Email | nodemailer → SMTP (credenziali via `.env`) |
|
||||
| Test | vitest (logica) + smoke test su build |
|
||||
| Versionamento | git dal primo commit, remote su GitLab |
|
||||
| Deploy (fase 2) | Dockerfile multi-stage + compose, gestito poi via Komodo sul VPS |
|
||||
|
||||
Motivazione: sito prevalentemente statico con una sola area dinamica (blog). Un'unica applicazione Node leggera evita la manutenzione di un CMS completo, mantiene ottime prestazioni e SEO, e resta estendibile (pagamenti in fase futura).
|
||||
|
||||
## Route
|
||||
|
||||
### Pubbliche
|
||||
|
||||
| Route | Contenuto | Fonte design |
|
||||
|---|---|---|
|
||||
| `/` | Homepage: hero slider, 3 colonne Performance/Balance/Longevity, feature alternata, banner citazione, Training (4 card), Programmi (6 icone), Agenda con tab, Team trainer, Metodo (4 step), loghi partner (Ticket Welfare Edenred, Welbee, AON, Howden), footer con mini-form | Homepage.pdf |
|
||||
| `/about` | Mission, direzione, approccio, about, timeline "Storylab" (2021-2024 aperta; +2025, +2026, +oggi ad accordion), convenzioni aziendali, team completo (9 persone), metodo, IN-SANITY STUDIOS, vision | Insanitylab.pdf |
|
||||
| `/training` | 4 servizi (One to One, Small Groups, Performance Class, Fit Remote) con blocchi alternati, agenda, CTA "prenota la lezione di prova" | Training.pdf |
|
||||
| `/programs` | Griglia 6 programmi con testi reali | Programmi.pdf |
|
||||
| `/programs/[slug]` | Pagina dettaglio per ogni programma: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition` | Performance class.pdf |
|
||||
| `/blog` | Blog & Edugo: intro, filtri per categoria, griglia articoli, paginazione | Blog & Edugo.pdf |
|
||||
| `/blog/[slug]` | Articolo singolo con sidebar (autore, categorie, ultimi articoli, archivio) | Articolo.pdf |
|
||||
| `/contact` | Form contatti (Nome, Cognome, Telefono, E-mail, Messaggio), mappa Google, recapiti | Contatti.pdf |
|
||||
|
||||
Dettaglio programmi: layout unico modellato su Performance class.pdf (FAQ ad accordion, descrizione, sidebar categorie + altri programmi). Il blocco pricing compare solo dove esistono prezzi — al lancio solo `performance-class` (3 piani semestrali: 130/234/300 € al mese, con varianti trimestrale e mensile ad accordion). I bottoni "acquista" puntano per ora a `/contact`. I testi degli altri cinque programmi partono dalle descrizioni di Programmi.pdf; il cliente li integrerà.
|
||||
|
||||
### Admin (richiedono sessione)
|
||||
|
||||
| Route | Funzione |
|
||||
|---|---|
|
||||
| `/admin/login` | Login user/password |
|
||||
| `/admin` | Elenco articoli (pubblicati e bozze) |
|
||||
| `/admin/new` | Nuovo articolo |
|
||||
| `/admin/edit/[id]` | Modifica articolo |
|
||||
|
||||
### API
|
||||
|
||||
| Endpoint | Funzione |
|
||||
|---|---|
|
||||
| `POST /api/contact` | Invio email form contatti (honeypot antispam + rate-limit) |
|
||||
| `POST/PUT/DELETE /api/admin/posts*` | CRUD articoli (sessione richiesta) |
|
||||
| `POST /api/admin/upload` | Upload immagini (copertina e corpo articolo, salvate in `public/uploads/`) |
|
||||
|
||||
## Design system
|
||||
|
||||
- **Palette** (valori esatti da campionare dai PDF in fase di implementazione): accento beige/tan ~`#b8a98a` (bottoni, etichette), marrone scuro ~`#3a2e28` (sezioni dark: agenda, banner citazione, footer, card scure), fondi chiari `#f5f3f0`/bianco.
|
||||
- **Tipografia**: Montserrat uppercase con letterspacing largo per titoli e bottoni; Open Sans per il corpo. File woff2 self-hosted (niente Google Fonts CDN, conformità GDPR).
|
||||
- **Componenti chiave**: header sticky (trasparente sopra l'hero, bianco con ombra allo scroll), hero slider (scroll-snap CSS + JS vanilla), card programma testo/foto alternate, agenda settimanale con tab per disciplina, accordion FAQ e pricing, form con stati di validazione.
|
||||
- **Responsive**: mobile-first; il Figma è solo desktop (1366 px), il comportamento mobile si deriva dalle convenzioni del template (menu hamburger sotto i 992 px, griglie che collassano a colonna).
|
||||
- **Immagini**: estratte dall'archivio `Web Insanity.fig` (è uno ZIP con cartella `images/`; i file hanno nomi hash e vanno identificati visivamente e rinominati per sezione). Fallback: asset in `Dati/` e estrazione da PDF con `pdfimages`. Ottimizzazione con `astro:assets` (webp, lazy loading, srcset).
|
||||
- **SEO**: title/description per pagina, Open Graph, sitemap, robots.txt, favicon dal logo.
|
||||
- **Mappa contatti**: iframe Google Maps con click-to-load (consenso prima del caricamento, GDPR).
|
||||
|
||||
## Backend
|
||||
|
||||
### Database
|
||||
|
||||
SQLite in `data/insanitylab.db` (fuori da `src/`, montabile come volume Docker in fase 2). Schema creato da script SQL idempotente all'avvio.
|
||||
|
||||
```sql
|
||||
posts (id, title, slug UNIQUE, category, excerpt, body_html,
|
||||
cover, published_at, draft, created_at, updated_at)
|
||||
users (id, username UNIQUE, password_hash)
|
||||
sessions (token PK, user_id, expires_at)
|
||||
```
|
||||
|
||||
Categorie blog fisse (da Figma): Articoli tecnici & scientifici, Contenuti educativi, LinkedIn / Insight, Eventi & Presidi.
|
||||
|
||||
Il corpo articolo è HTML prodotto da TipTap, sanitizzato lato server prima del salvataggio e del rendering.
|
||||
|
||||
### Autenticazione
|
||||
|
||||
- Password con hash bcrypt; utenti creati via script CLI (`npm run create-user`).
|
||||
- Sessione: token random in cookie httpOnly, SameSite=Lax, scadenza 7 giorni, verificata da middleware su tutte le route `/admin` e `/api/admin`.
|
||||
- Rate-limit sul login (protezione brute force).
|
||||
|
||||
### Email
|
||||
|
||||
`POST /api/contact` valida i campi, applica honeypot e rate-limit, invia via nodemailer con SMTP configurato in `.env` (host, porta, credenziali, destinatario — default `info@insanitylab.it`). Risposta JSON per feedback inline nel form.
|
||||
|
||||
### Dati statici
|
||||
|
||||
Agenda settimanale, prezzi, team, programmi, loghi partner: file TypeScript/JSON in `src/data/`, modificati dallo sviluppatore e versionati in git. Nessuna gestione da admin per questi contenuti al lancio.
|
||||
|
||||
## Deploy
|
||||
|
||||
- **Fase attuale (locale)**: `npm run dev`; build verificata con `npm run build && npm run preview`.
|
||||
- **Versionamento**: repository git con remote su GitLab (namespace da confermare in fase di implementazione).
|
||||
- **Fase 2 (VPS)**: Dockerfile multi-stage (build → `node:22-slim`), `compose.yaml` con volumi per `data/` e `public/uploads/`, porta interna 4321; aggancio a Komodo e reverse proxy per `insanitylab.tielogic.xyz` quando si porterà in produzione. Fuori scope di questa spec.
|
||||
|
||||
## Gestione errori
|
||||
|
||||
- Form contatti: validazione client + server, messaggi di errore inline, fallback se SMTP non risponde (errore esplicito all'utente, log lato server).
|
||||
- Admin: redirect a `/admin/login` se sessione assente/scaduta; conferma prima dell'eliminazione articoli.
|
||||
- 404 personalizzata coerente col design.
|
||||
- Upload: limite dimensione (5 MB) e whitelist tipi immagine.
|
||||
|
||||
## Test e verifica
|
||||
|
||||
- vitest su: helper slug, validazione form, auth (hash/sessioni), query posts.
|
||||
- Smoke test: build completa senza errori; fetch delle route principali con risposta 200 e contenuti attesi.
|
||||
- Verifica visiva locale contro i PDF Figma pagina per pagina.
|
||||
|
||||
## Fuori scope (fasi future)
|
||||
|
||||
- Pagamenti online piani Performance Class (Stripe o simile).
|
||||
- Gestione da admin di agenda, prezzi, team.
|
||||
- Porting su VPS, DNS, TLS (fase 2 dedicata).
|
||||
- Ricerca nel blog e ricerca globale (icona lente del template: omessa al lancio).
|
||||
- E-commerce/carrello (icona carrello nell'header Figma: omessa al lancio, era ereditata dal template).
|
||||
@@ -1,61 +0,0 @@
|
||||
# Animazioni stile template Prowess — Design
|
||||
|
||||
Data: 2026-07-02 · Stato: approvato dall'operatore
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Il sito InsanityLab, oggi quasi del tutto statico, deve riprendere il linguaggio di animazione del template WordPress "Prowess" (Qode Interactive) da cui deriva il design Figma. L'operatore ha scelto il pacchetto completo: reveal allo scroll, parallax degli sfondi, animazione d'ingresso della hero, effetti hover su card e team, e citazione con testo a scorrimento continuo.
|
||||
|
||||
## Evidenze raccolte dal template
|
||||
|
||||
Dalla demo ufficiale `prowess.qodeinteractive.com` (cassetta.me è in manutenzione):
|
||||
|
||||
- gli elementi appaiono allo scroll con un offset di attivazione di −100 px dal viewport (`qodefElementAppearAmount: -100`);
|
||||
- due sezioni usano parallax di sfondo (`data-parallax-bg-speed="1"`);
|
||||
- la hero è uno Slider Revolution con testi che entrano in sequenza;
|
||||
- card e foto hanno hover con zoom immagine e transizioni morbide.
|
||||
|
||||
## Decisioni
|
||||
|
||||
1. **Implementazione vanilla**: CSS + IntersectionObserver, nessuna libreria. Il sito non ha dipendenze frontend e resta così; costo totale ~2 KB.
|
||||
2. **Citazione**: si mantiene il banner su foto scura del Figma, ma il testo della citazione scorre in orizzontale a loop infinito come il marquee del template (scelta esplicita dell'operatore). La firma "Insanitylab" resta statica.
|
||||
3. **Accessibilità**: con `prefers-reduced-motion: reduce` ogni animazione è disattivata (reveal, marquee, parallax, hero). Senza JavaScript il sito resta interamente visibile: lo stato nascosto si applica solo sotto `html.js`.
|
||||
|
||||
## Architettura
|
||||
|
||||
- **`src/styles/global.css`** — utilities: `[data-reveal]` (stato iniziale `opacity: 0` + `translateY(40px)`, transizione `.9s ease`; varianti `left`, `right`, `fade`; ritardo per stagger via variabile `--reveal-delay` impostata inline), keyframes marquee, blocco `prefers-reduced-motion`.
|
||||
- **`src/layouts/Base.astro`** — uno script inline in `<head>` aggiunge la classe `js` a `<html>` prima del paint (evita flash); uno script bundled attiva l'IntersectionObserver (`rootMargin: 0 0 -100px`, `unobserve` dopo il primo ingresso) e il parallax (`transform: translateY` calcolato su scroll con `requestAnimationFrame`, velocità ~0.3, attivo solo da 992 px in su e senza reduced-motion).
|
||||
- **Componenti** — ricevono attributi `data-reveal` e stagger inline; nessuna modifica strutturale se non il wrapper overflow per gli zoom hover e la ristrutturazione del banner citazione (sfondo su div dedicato trasformabile, testo in marquee con copia `aria-hidden`).
|
||||
|
||||
## Dettaglio per componente
|
||||
|
||||
| Componente | Effetto |
|
||||
|---|---|
|
||||
| HeroSlider | Ingresso al load: righe del titolo slide-up in sequenza (stagger 150 ms), bottone dopo, immagine da destra con fade |
|
||||
| InfoColumns | Reveal fade con stagger 0/150/300 ms |
|
||||
| FeatureBlocks | Immagine entra dal proprio lato, testo dal lato opposto |
|
||||
| QuoteBanner | Sfondo parallax + testo citazione in marquee loop (pausa su hover), firma statica |
|
||||
| TrainingCards | Reveal con stagger; hover zoom foto (wrapper overflow hidden) |
|
||||
| ProgramsGrid | Heading reveal; item con stagger; hover: traslazione -4 px e colore |
|
||||
| Agenda | Reveal heading e tabella |
|
||||
| TeamSection | Head reveal; card con stagger; hover zoom foto |
|
||||
| MethodSteps | Heading reveal; step con stagger |
|
||||
| PartnersStrip | Reveal fade dell'intera striscia |
|
||||
| PageHero (pagine interne) | Reveal del titolo all'ingresso pagina |
|
||||
| Header | Transizione morbida dell'ombra già presente |
|
||||
| Bottoni `.btn` | Freccia trasla a destra su hover |
|
||||
|
||||
## Errori e casi limite
|
||||
|
||||
- JS assente: nessun elemento nascosto (gate su `html.js`).
|
||||
- Reduced motion: contenuto immediatamente visibile, marquee fermo su testo statico multilinea.
|
||||
- Il marquee usa due copie identiche del segmento (`translateX(-50%)`) per loop senza scatti; la seconda copia è `aria-hidden`.
|
||||
- Solo `opacity` e `transform`: nessun layout shift, nessun impatto CLS.
|
||||
|
||||
## Verifica
|
||||
|
||||
Build senza errori, vitest esistenti verdi, controllo visivo locale su homepage e pagine interne, poi deploy su VPS e smoke test produzione.
|
||||
|
||||
## Fuori scope
|
||||
|
||||
Slider hero multi-slide automatico (oggi esiste una sola slide), animazioni admin, contatori animati e BMI calculator (assenti dal design Figma).
|
||||
@@ -9,7 +9,6 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.0.1",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@tiptap/core": "^3.27.1",
|
||||
@@ -360,17 +359,6 @@
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/sitemap": {
|
||||
"version": "3.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz",
|
||||
"integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sitemap": "^9.0.0",
|
||||
"stream-replace-string": "^2.0.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/telemetry": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz",
|
||||
@@ -2512,6 +2500,7 @@
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -2537,15 +2526,6 @@
|
||||
"htmlparser2": "^10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sax": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
|
||||
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -2881,12 +2861,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -5622,25 +5596,6 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sitemap": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
|
||||
"integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/sax": "^1.2.1",
|
||||
"arg": "^5.0.0",
|
||||
"sax": "^1.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"sitemap": "dist/esm/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.5",
|
||||
"npm": ">=10.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
|
||||
@@ -5695,12 +5650,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stream-replace-string": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
|
||||
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -5972,6 +5921,7 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.0.1",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@tiptap/core": "^3.27.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
|
||||
|
||||
@@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const [, , username, password] = process.argv;
|
||||
const [, , username, password, role = 'admin'] = process.argv;
|
||||
if (!username || !password) {
|
||||
console.error('Uso: npm run create-user -- <username> <password>');
|
||||
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!['admin', 'superuser', 'user'].includes(role)) {
|
||||
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
|
||||
process.exit(1);
|
||||
}
|
||||
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||
@@ -14,10 +18,11 @@ const db = new Database(path);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
);`);
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
|
||||
).run(username, bcrypt.hashSync(password, 12));
|
||||
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role`
|
||||
).run(username, bcrypt.hashSync(password, 12), role);
|
||||
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
|
||||
|
||||
@@ -6,7 +6,7 @@ PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
|
||||
trap 'kill $SRV' EXIT
|
||||
sleep 2
|
||||
fail=0
|
||||
for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do
|
||||
for path in / /about /training /services /services/performance-class /services/rehab /blog /contact /admin/login /robots.txt; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
|
||||
echo "$code $path"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
@@ -14,4 +14,38 @@ done
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
|
||||
echo "$code /admin (atteso 302)"
|
||||
[ "$code" = "302" ] || fail=1
|
||||
|
||||
# Contenuti taggati
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/sitemap.xml)
|
||||
echo "$code /sitemap.xml"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
locs=$(curl -s http://localhost:4399/sitemap.xml | grep -c '<loc>')
|
||||
echo "$locs <loc> in sitemap (atteso >= 12)"
|
||||
[ "$locs" -ge 12 ] || fail=1
|
||||
tags=$(curl -s http://localhost:4399/ | grep -c 'data-tag=')
|
||||
echo "$tags righe con data-tag= in home (atteso >= 1)"
|
||||
[ "$tags" -ge 1 ] || fail=1
|
||||
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
|
||||
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
|
||||
[ "$badges" = "0" ] || fail=1
|
||||
|
||||
# /login pubblico raggiungibile
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
|
||||
echo "$code /login"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
|
||||
# Header mostra "Accedi" da anonimo
|
||||
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
|
||||
|
||||
# Nessun badge tag-badge da anonimo sulla home semplice
|
||||
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
|
||||
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
|
||||
[ "$badges2" = "0" ] || fail=1
|
||||
|
||||
# Rotta gestione utenti protetta (redirect al login da anonimo)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
|
||||
echo "$code /admin/users (atteso 302)"
|
||||
[ "$code" = "302" ] || fail=1
|
||||
|
||||
echo "smoke ruoli/login/inline OK"
|
||||
exit $fail
|
||||
|
||||
|
After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -1,20 +1,21 @@
|
||||
---
|
||||
import { agenda, disciplines, days, slots } from '../data/agenda';
|
||||
import T from './content/T.astro';
|
||||
---
|
||||
<section class="section section--dark agenda">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal><h2>Agenda</h2></div>
|
||||
<div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
|
||||
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
|
||||
{disciplines.map((d, i) => (
|
||||
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
|
||||
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
|
||||
))}
|
||||
</div>
|
||||
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
|
||||
<div class="agenda__corner"></div>
|
||||
{days.map((d) => <div class="agenda__day">{d}</div>)}
|
||||
{slots.map((slot) => (
|
||||
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
|
||||
{slots.map((slot, si) => (
|
||||
<>
|
||||
<div class="agenda__slot">{slot}</div>
|
||||
<T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
|
||||
{days.map((_, dayIdx) => {
|
||||
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
|
||||
return entry
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
---
|
||||
import { t } from '../lib/content';
|
||||
import T from './content/T.astro';
|
||||
interface Props { variant?: 'full' | 'footer' }
|
||||
const { variant = 'full' } = Astro.props;
|
||||
---
|
||||
<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate>
|
||||
<input class="field" name="firstName" placeholder="Nome" required maxlength="100" />
|
||||
{variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />}
|
||||
{variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />}
|
||||
<input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" />
|
||||
<textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea>
|
||||
<form
|
||||
class:list={['cform', `cform--${variant}`]}
|
||||
method="post"
|
||||
action="/api/contact"
|
||||
novalidate
|
||||
data-msg-success={t('form.msg.success')}
|
||||
data-msg-error-generic={t('form.msg.error-generic')}
|
||||
data-msg-error-network={t('form.msg.error-network')}
|
||||
>
|
||||
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
|
||||
{variant === 'full' && <input class="field" name="lastName" placeholder={t('form.field.last-name')} maxlength="100" />}
|
||||
{variant === 'full' && <input class="field" name="phone" placeholder={t('form.field.phone')} maxlength="40" />}
|
||||
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
|
||||
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
|
||||
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
<button class="btn" type="submit">Invia messaggio</button>
|
||||
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
|
||||
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
|
||||
<p class="form-msg" hidden></p>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.cform__privacy { font-size: .74rem; opacity: .8; margin: 8px 0 0; line-height: 1.5; }
|
||||
.cform__privacy a { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
@@ -28,11 +44,11 @@ const { variant = 'full' } = Astro.props;
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const data = await res.json();
|
||||
msg.textContent = res.ok ? 'Messaggio inviato, ti ricontatteremo al più presto.' : (data.error ?? 'Errore di invio, riprova più tardi.');
|
||||
msg.textContent = res.ok ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
|
||||
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
|
||||
if (res.ok) form.reset();
|
||||
} catch {
|
||||
msg.textContent = 'Errore di rete, riprova più tardi.';
|
||||
msg.textContent = form.dataset.msgErrorNetwork!;
|
||||
msg.className = 'form-msg form-msg--err';
|
||||
}
|
||||
msg.hidden = false;
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import ContactForm from './ContactForm.astro';
|
||||
import { t, contentImageUrl } from '../lib/content';
|
||||
import T from './content/T.astro';
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
<footer class="ftr">
|
||||
<div class="container ftr__grid">
|
||||
<div>
|
||||
<img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" />
|
||||
<p class="ftr__tag">PERFORMANCE. BALANCE. LONGEVITY.</p>
|
||||
<p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p>
|
||||
<p>{site.phone}<br />{site.address}, {site.cityLine}</p>
|
||||
{site.hours.map((h) => <p class="ftr__hours">{h}</p>)}
|
||||
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
|
||||
<T tag="footer.tagline" as="p" class="ftr__tag" />
|
||||
<T tag="footer.about" as="p" />
|
||||
<p>{t('global.contact.address')}, {t('global.contact.city-line')}<br />{t('global.contact.phone')}<br /><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
|
||||
<p class="ftr__hours">{t('global.hours.1')}</p>
|
||||
<p class="ftr__hours">{t('global.hours.2')}</p>
|
||||
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
|
||||
</div>
|
||||
<nav aria-label="training">
|
||||
<h4>Training</h4>
|
||||
{site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)}
|
||||
<T tag="footer.col.training-title" as="h4" />
|
||||
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
|
||||
</nav>
|
||||
<nav aria-label="programmi">
|
||||
<h4>Programmi</h4>
|
||||
{site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)}
|
||||
<nav aria-label="servizi">
|
||||
<T tag="footer.col.services-title" as="h4" />
|
||||
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
|
||||
</nav>
|
||||
<div>
|
||||
<h4>Contact</h4>
|
||||
<T tag="footer.col.contact-title" as="h4" />
|
||||
<ContactForm variant="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ftr__bar">
|
||||
<div class="container ftr__bar-in">
|
||||
<p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p>
|
||||
<p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
|
||||
<p>© {year} <T tag="footer.copyright" as="span" /> <span class="ftr__sep">·</span> <a href="/privacy">Privacy Policy</a> <span class="ftr__sep">·</span> <a href="/cookie-policy">Cookie Policy</a></p>
|
||||
<p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -41,6 +45,8 @@ const year = new Date().getFullYear();
|
||||
.ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
|
||||
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
|
||||
.ftr__hours { margin: 0; }
|
||||
.ftr__legal { margin-top: 14px; font-size: .78rem; opacity: .8; }
|
||||
.ftr__bar-in .ftr__sep { margin: 0 4px; opacity: .5; }
|
||||
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
|
||||
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
.ftr__bar-in a { display: inline; margin-left: 12px; }
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import { t, contentImageUrl } from '../lib/content';
|
||||
interface Props {
|
||||
user: { username: string; role: string } | null;
|
||||
showTags: boolean;
|
||||
canTags: boolean;
|
||||
}
|
||||
const { user, canTags, showTags } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const loginHref = `/login?next=${encodeURIComponent(path)}`;
|
||||
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
||||
---
|
||||
<header class="hdr" id="site-header">
|
||||
<div class="container hdr__in">
|
||||
<a href="/" class="hdr__logo"><img src="/img/logo-dark.png" alt="InsanityLab" width="150" /></a>
|
||||
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
|
||||
<nav class="hdr__nav" id="site-nav" aria-label="principale">
|
||||
{site.navigation.map((item) => (
|
||||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a>
|
||||
{site.navigation.map((item, i) => (
|
||||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="hdr__user">
|
||||
{user ? (
|
||||
<details class="umenu">
|
||||
<summary class="umenu__btn">{user.username} ▾</summary>
|
||||
<div class="umenu__panel">
|
||||
<a href={areaHref}>Area riservata</a>
|
||||
{canTags && (
|
||||
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
|
||||
)}
|
||||
<a href="/admin/logout">Esci</a>
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<a class="hdr__login" href={loginHref}>Accedi</a>
|
||||
)}
|
||||
</div>
|
||||
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
@@ -31,6 +56,15 @@ const path = Astro.url.pathname;
|
||||
.hdr__link { padding: 12px 0; }
|
||||
.hdr__burger { display: block; }
|
||||
}
|
||||
.hdr__user { display: flex; align-items: center; }
|
||||
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||||
.hdr__login:hover { color: var(--c-accent-dark); }
|
||||
.umenu { position: relative; }
|
||||
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
|
||||
.umenu__btn::-webkit-details-marker { display: none; }
|
||||
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
|
||||
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
|
||||
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -42,4 +76,18 @@ const path = Astro.url.pathname;
|
||||
const open = nav.classList.toggle('is-open');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
|
||||
const tagsBtn = document.getElementById('toggle-tags');
|
||||
if (tagsBtn) {
|
||||
const on = tagsBtn.dataset.on === '1';
|
||||
tagsBtn.addEventListener('click', async () => {
|
||||
const res = await fetch('/api/admin/showtags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ on: !on }),
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Operazione non riuscita.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
---
|
||||
interface Props { title: string; eyebrow?: string }
|
||||
const { title, eyebrow } = Astro.props;
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
|
||||
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
|
||||
const { title, eyebrow, bgImage } = Astro.props;
|
||||
const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
|
||||
---
|
||||
<section class="phero">
|
||||
<section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
|
||||
<div class="container" data-reveal>
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h1>{title}</h1>
|
||||
@@ -10,6 +14,12 @@ const { title, eyebrow } = Astro.props;
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.phero { background: var(--c-bg-alt); padding-block: 70px; }
|
||||
.phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
|
||||
/* Banner scuro come nel Figma: ~460px, titolo bianco maiuscolo ~44px */
|
||||
.phero { background: var(--c-dark); min-height: 380px; display: flex; align-items: center; }
|
||||
.phero .container { width: 100%; }
|
||||
.phero h1 { text-transform: uppercase; font-weight: 500; color: #fff; font-size: clamp(2.2rem, 4vw, 2.75rem); letter-spacing: .04em; margin: 0; }
|
||||
.phero--photo { position: relative; background-size: cover; background-position: center; }
|
||||
.phero--photo::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.6); }
|
||||
.phero--photo .container { position: relative; }
|
||||
@media (max-width: 767px) { .phero { min-height: 240px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
---
|
||||
const openText = `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à.`;
|
||||
const stages = [
|
||||
{ label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
|
||||
{ label: 'INSANITYLAB 2026', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
|
||||
{ label: 'INSANITYLAB OGGI', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
|
||||
];
|
||||
import T from '../content/T.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const stages = [1, 2, 3];
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<h2>Storylab</h2>
|
||||
<p>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.</p>
|
||||
<T tag="about.storylab.title" as="h2" />
|
||||
<T tag="about.storylab.body" as="p" />
|
||||
</div>
|
||||
<div class="tl">
|
||||
<h3 class="tl__label">INSANITYLAB 2021-2024</h3>
|
||||
<p>{openText}</p>
|
||||
{stages.map((s) => (
|
||||
<T tag="about.timeline.label" as="h3" class="tl__label" />
|
||||
<T tag="about.timeline.open-text" as="p" />
|
||||
{stages.map((n) => (
|
||||
<details class="tl__stage">
|
||||
<summary>+ {s.label}</summary>
|
||||
<p>{s.text}</p>
|
||||
<summary>+ {t(`about.timeline.stage${n}.label`)}</summary>
|
||||
<T tag={`about.timeline.stage${n}.text`} as="p" />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { resolveContent } from '../../lib/content';
|
||||
interface Props { tag: string; as?: string; class?: string }
|
||||
const { tag, as = 'span', class: cls } = Astro.props;
|
||||
const Tag = as;
|
||||
const c = resolveContent(tag);
|
||||
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
|
||||
---
|
||||
{c.type === 'html'
|
||||
? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
|
||||
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { resolveContent } from '../../lib/content';
|
||||
interface Props {
|
||||
tag: string; src: ImageMetadata; alt: string;
|
||||
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
|
||||
}
|
||||
const { tag, src, alt, class: cls, ...rest } = Astro.props;
|
||||
const c = resolveContent(tag);
|
||||
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
|
||||
const guid = c.guid || undefined;
|
||||
---
|
||||
{override
|
||||
? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
|
||||
: <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
|
||||
@@ -1,26 +1,25 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import featPerformance from '../../assets/img/feature-performance.jpg';
|
||||
import featBalance from '../../assets/img/feature-balance.jpg';
|
||||
import featLongevity from '../../assets/img/feature-longevity.jpg';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const blocks = [
|
||||
{ title: 'PERFORMANCE', image: featPerformance, reverse: false,
|
||||
text: 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.' },
|
||||
{ title: 'BALANCE', image: featBalance, reverse: true,
|
||||
text: 'L’equilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.' },
|
||||
{ title: 'LONGEVITY', image: featLongevity, reverse: false,
|
||||
text: 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.' },
|
||||
{ key: 'performance', image: featPerformance, reverse: false },
|
||||
{ key: 'balance', image: featBalance, reverse: true },
|
||||
{ key: 'longevity', image: featLongevity, reverse: false },
|
||||
];
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
{blocks.map((b) => (
|
||||
<div class:list={['feat', { 'feat--reverse': b.reverse }]}>
|
||||
<Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
|
||||
<TImg tag={`home.feature.${b.key}.image`} src={b.image} alt={t(`home.feature.${b.key}.title`)} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
|
||||
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
|
||||
<h2>{b.title}</h2>
|
||||
<p>{b.text}</p>
|
||||
<a class="btn" href="/programs">Scopri</a>
|
||||
<T tag={`home.feature.${b.key}.title`} as="h2" />
|
||||
<T tag={`home.feature.${b.key}.body`} as="p" />
|
||||
<a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import hero1 from '../../assets/img/hero-1.jpg';
|
||||
import hero1 from '../../assets/img/hero-1.png';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const slides = [
|
||||
{ title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' },
|
||||
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'], image: hero1, alt: 'Atleti InsanityLab' },
|
||||
];
|
||||
---
|
||||
<section class="hero" aria-label="presentazione">
|
||||
@@ -10,10 +12,10 @@ const slides = [
|
||||
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
|
||||
<div class="container hero__in">
|
||||
<div class="hero__text">
|
||||
<h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
|
||||
<a class="btn" href="/programs">Scopri</a>
|
||||
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
|
||||
<a class="btn" href="/services">{t('home.hero.cta')}</a>
|
||||
</div>
|
||||
<Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||||
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -30,25 +32,34 @@ const slides = [
|
||||
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
|
||||
.hero__slide { display: none; }
|
||||
.hero__slide.is-active { display: block; }
|
||||
.hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
|
||||
.hero__line { display: block; }
|
||||
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
|
||||
testo sovrapposto sulla sinistra. */
|
||||
.hero__in { position: relative; display: flex; align-items: center; min-height: calc(100vh - var(--header-h)); }
|
||||
/* Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
|
||||
di questo componente: si ancora a .hero__text (che il cid ce l'ha) + :global. */
|
||||
.hero__text h1 :global(.hero__line) { display: block; }
|
||||
.hero__text { position: relative; z-index: 1; max-width: 46%; }
|
||||
.hero__text h1 { font-weight: 500; }
|
||||
.hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); width: 100%; }
|
||||
.hero__img { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
|
||||
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
|
||||
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
|
||||
@media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
|
||||
@media (max-width: 991px) {
|
||||
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
|
||||
.hero__text { max-width: none; }
|
||||
.hero__img { position: static; max-width: 100%; height: auto; }
|
||||
}
|
||||
|
||||
/* Ingresso al load stile Slider Revolution del template */
|
||||
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes hero-side { from { opacity: 0; transform: translateX(60px); } to { opacity: 1; transform: none; } }
|
||||
:global(html.js) .hero__line { opacity: 0; animation: hero-up .8s ease forwards; }
|
||||
:global(html.js) .hero__line:nth-child(1) { animation-delay: .15s; }
|
||||
:global(html.js) .hero__line:nth-child(2) { animation-delay: .3s; }
|
||||
:global(html.js) .hero__line:nth-child(3) { animation-delay: .45s; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line) { opacity: 0; animation: hero-up .8s ease forwards; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(1)) { animation-delay: .15s; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(2)) { animation-delay: .3s; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(3)) { animation-delay: .45s; }
|
||||
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
|
||||
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(html.js) .hero__line, :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
---
|
||||
import { getImage } from 'astro:assets';
|
||||
import infoPerformance from '../../assets/img/info-performance.jpg';
|
||||
import infoBalance from '../../assets/img/info-balance.jpg';
|
||||
import infoLongevity from '../../assets/img/info-longevity.jpg';
|
||||
const cols = [
|
||||
{ title: 'PERFORMANCE.', image: infoPerformance },
|
||||
{ title: 'BALANCE.', image: infoBalance },
|
||||
{ title: 'LONGEVITY.', image: infoLongevity },
|
||||
];
|
||||
import T from '../content/T.astro';
|
||||
import { contentImageUrl } from '../../lib/content';
|
||||
// getImage garantisce l'emissione dell'asset anche quando il file è byte-identico
|
||||
// ad altri import (Astro dedupa e non emette l'originale usato solo come background).
|
||||
const cols = await Promise.all([
|
||||
{ titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
|
||||
{ titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
|
||||
{ titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
|
||||
].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
|
||||
---
|
||||
<section class="info" aria-label="pilastri">
|
||||
{cols.map((c, i) => (
|
||||
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src});--reveal-delay:${i * 0.15}s`} data-reveal="fade">
|
||||
<span class="info__title">{c.title}</span>
|
||||
<a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
|
||||
<T tag={c.titleTag} as="span" class="info__title" />
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
|
||||
/* Proporzioni Figma: colonna 413x308 su frame 1209 — l'altezza scala con la larghezza */
|
||||
.info__col { position: relative; aspect-ratio: 413 / 308; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
|
||||
.info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
|
||||
.info__col:hover::before { background: rgba(30,24,20,.15); }
|
||||
.info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
|
||||
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } .info__col { aspect-ratio: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { methodIntro, methodExtraNote, methodSteps } from '../../data/method';
|
||||
import { methodSteps } from '../../data/method';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
---
|
||||
<section class="section section--alt">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal>
|
||||
<h2>Il metodo<br />InsanityLab</h2>
|
||||
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
|
||||
<T tag="home.method.title" as="h2" />
|
||||
<T tag="method.intro" as="p" />
|
||||
</div>
|
||||
<p class="method__note">{methodExtraNote}</p>
|
||||
<T tag="method.extra-note" as="p" class="method__note" />
|
||||
<div class="method__row">
|
||||
{methodSteps.map((s, i) => (
|
||||
<div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
|
||||
<div class="method__circle">
|
||||
{imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||||
{imgOf(s.image) && <TImg tag={`method.step.${s.n}.image`} src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||||
<span>{s.n}</span>
|
||||
</div>
|
||||
<h3>{s.title}</h3>
|
||||
<p>{s.text}</p>
|
||||
<T tag={`method.step.${s.n}.title`} as="h3" />
|
||||
<T tag={`method.step.${s.n}.text`} as="p" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="method__cta" data-reveal>
|
||||
<a class="btn btn--dark" href="/contact"><T tag="home.method.cta" as="span" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
|
||||
.method__cta { text-align: center; margin-top: 48px; }
|
||||
.method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
|
||||
.method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
|
||||
.method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
|
||||
/* :global(img) perché gli <img> di override resi da <TImg> non ereditano il cid
|
||||
del componente: senza questo la regola scoped non li raggiungerebbe. */
|
||||
.method__circle :global(img) { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
|
||||
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
|
||||
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
|
||||
.method__step p { font-size: .85rem; color: var(--c-text-light); }
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { partners } from '../../data/partners';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
---
|
||||
<section class="pstrip" aria-label="partner">
|
||||
<div class="container pstrip__in" data-reveal="fade">
|
||||
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
|
||||
{partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.name`)} height={44} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
---
|
||||
import quoteBg from '../../assets/img/quote-bg.jpg';
|
||||
const quote = 'PERFORMANCE È SPINGERE OLTRE I PROPRI LIMITI, BALANCE È SAPERLI ASCOLTARE, LONGEVITY È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.';
|
||||
const quoteHtml = quote.replace(/(PERFORMANCE|BALANCE|LONGEVITY)/g, '<strong>$1</strong>');
|
||||
import { getImage } from 'astro:assets';
|
||||
import quoteBgSrc from '../../assets/img/quote-bg.jpg';
|
||||
import { t, contentImageUrl } from '../../lib/content';
|
||||
const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
|
||||
---
|
||||
<section class="quote" data-parallax>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg.src})`}></div>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
|
||||
<div class="quote__in">
|
||||
<p class="quote__mark">“</p>
|
||||
<blockquote>
|
||||
<div class="quote__marquee">
|
||||
<p class="quote__track">
|
||||
<span class="quote__seg" set:html={quoteHtml} />
|
||||
<span class="quote__seg" aria-hidden="true" set:html={quoteHtml} />
|
||||
<span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
<span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
</p>
|
||||
</div>
|
||||
<cite>Insanitylab</cite>
|
||||
<cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
---
|
||||
import { programs } from '../../data/programs';
|
||||
const icons: Record<string, string> = {
|
||||
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
|
||||
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
|
||||
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
|
||||
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
|
||||
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
|
||||
};
|
||||
import { services } from '../../data/services';
|
||||
import { serviceIcons as icons } from '../../data/service-icons';
|
||||
import T from '../content/T.astro';
|
||||
|
||||
// Stessa convenzione di /services: le voci con grid:false (Small Group, Rehab,
|
||||
// Holistic Class, Reformer Class) sono approfondimenti dei riquadri Training e
|
||||
// non compaiono nella griglia Servizi.
|
||||
const gridServices = services.filter((p) => p.grid !== false);
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal>
|
||||
<h2>Programmi</h2>
|
||||
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
|
||||
<T tag="home.services.title" as="h2" />
|
||||
<T tag="home.services.body" as="p" />
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{programs.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
{gridServices.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
|
||||
<h3>{p.title}</h3>
|
||||
<p>{p.subtitle}</p>
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="p" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -1,24 +1,29 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { team, homeTeam } from '../../data/team';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
|
||||
const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(`${name}.`))?.[1].default;
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="tteam__head" data-reveal>
|
||||
<h2>Team<br />Trainer</h2>
|
||||
<a class="btn" href="/about">Scopri team</a>
|
||||
<T tag="home.team.title" as="h2" />
|
||||
<a class="btn" href="/about"><T tag="home.team.cta" as="span" /></a>
|
||||
</div>
|
||||
<div class="tteam__grid">
|
||||
{members.map((m, i) => (
|
||||
{members.map((m, i) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
|
||||
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<figcaption>{m.shortName}</figcaption>
|
||||
<TImg tag={`team.${slug}.image`} src={imgOf(m.image!)!} alt={t(`team.${slug}.name`)} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<T tag={`team.${slug}.short-name`} as="figcaption" />
|
||||
</figure>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { trainings } from '../../data/trainings';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
||||
@@ -9,18 +11,18 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
||||
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
||||
---
|
||||
<section class="tcards">
|
||||
{trainings.map((t, i) => {
|
||||
const img = imgOf(t.image);
|
||||
{trainings.map((tr, i) => {
|
||||
const img = imgOf(tr.image);
|
||||
const card = (
|
||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
||||
<h3>{t.title}</h3>
|
||||
<p>{t.caption}</p>
|
||||
<a class="tcards__link" href={t.cta.href}>Scopri →</a>
|
||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
||||
<T tag={`training.${tr.id}.title`} as="h3" />
|
||||
<T tag={`training.${tr.id}.caption`} as="p" />
|
||||
<a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
|
||||
</div>
|
||||
);
|
||||
const photo = img ? (
|
||||
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
|
||||
<Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<TImg tag={`training.${tr.id}.image`} src={img} alt={t(`training.${tr.id}.title`)} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
</div>
|
||||
) : null;
|
||||
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { StyleGroup } from '../lib/style-presets';
|
||||
import { site } from './site';
|
||||
import { trainings } from './trainings';
|
||||
import { team } from './team';
|
||||
import { methodIntro, methodExtraNote, methodSteps } from './method';
|
||||
import { partners } from './partners';
|
||||
import { disciplines, days, slots } from './agenda';
|
||||
import { services } from './services';
|
||||
import { performanceClassPricing } from './pricing';
|
||||
|
||||
export type ContentType = 'text' | 'html' | 'image';
|
||||
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
|
||||
|
||||
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style', 'align']): SeedEntry =>
|
||||
({ tag, type: 'text', value, styleOptions });
|
||||
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'align']): SeedEntry =>
|
||||
({ tag, type: 'html', value, styleOptions });
|
||||
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
|
||||
|
||||
export const contentSeed: SeedEntry[] = [
|
||||
// --- Globali (site.ts) ---
|
||||
T('global.contact.phone', site.phone, []),
|
||||
T('global.contact.email', site.email, []),
|
||||
T('global.contact.address', site.address, []),
|
||||
T('global.contact.city-line', site.cityLine, []),
|
||||
T('global.contact.map-query', site.mapQuery, []),
|
||||
T('global.whatsapp', '351 753 5977', []),
|
||||
T('global.hours.1', site.hours[0], []),
|
||||
T('global.hours.2', site.hours[1], []),
|
||||
T('global.nav.1.label', site.navigation[0].label, []),
|
||||
T('global.nav.2.label', site.navigation[1].label, []),
|
||||
T('global.nav.3.label', site.navigation[2].label, []),
|
||||
T('global.nav.4.label', site.navigation[3].label, []),
|
||||
T('global.nav.5.label', site.navigation[4].label, []),
|
||||
T('global.nav.6.label', site.navigation[5].label, []),
|
||||
|
||||
// --- Header ---
|
||||
T('header.logo.alt', 'InsanityLab', []),
|
||||
I('header.logo.image'),
|
||||
|
||||
// --- Footer ---
|
||||
I('footer.logo.image'),
|
||||
H('footer.tagline', 'PERFORMANCE. BALANCE. LONGEVITY.'),
|
||||
T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'),
|
||||
T('footer.col.training-title', 'Training'),
|
||||
T('footer.col.services-title', 'Servizi'),
|
||||
T('footer.col.contact-title', 'Contact'),
|
||||
H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'),
|
||||
T('footer.social-label', 'Seguici', []),
|
||||
T('footer.training.1.label', site.footerTraining[0].label, []),
|
||||
T('footer.training.2.label', site.footerTraining[1].label, []),
|
||||
T('footer.training.3.label', site.footerTraining[2].label, []),
|
||||
T('footer.training.4.label', site.footerTraining[3].label, []),
|
||||
T('footer.training.5.label', site.footerTraining[4].label, []),
|
||||
T('footer.training.6.label', site.footerTraining[5].label, []),
|
||||
T('footer.services.1.label', site.footerServices[0].label, []),
|
||||
T('footer.services.2.label', site.footerServices[1].label, []),
|
||||
T('footer.services.3.label', site.footerServices[2].label, []),
|
||||
T('footer.services.4.label', site.footerServices[3].label, []),
|
||||
T('footer.services.5.label', site.footerServices[4].label, []),
|
||||
T('footer.services.6.label', site.footerServices[5].label, []),
|
||||
|
||||
// --- Contact form ---
|
||||
T('form.field.first-name', 'Nome', []),
|
||||
T('form.field.last-name', 'Cognome', []),
|
||||
T('form.field.phone', 'Telefono', []),
|
||||
T('form.field.email', 'E-mail', []),
|
||||
T('form.field.message', 'Messaggio', []),
|
||||
T('form.submit', 'Invia messaggio'),
|
||||
T('form.msg.success', 'Messaggio inviato, ti ricontatteremo al più presto.', []),
|
||||
T('form.msg.error-generic', 'Errore di invio, riprova più tardi.', []),
|
||||
T('form.msg.error-network', 'Errore di rete, riprova più tardi.', []),
|
||||
|
||||
// --- Home: meta, training heading ---
|
||||
T('home.meta.title', 'Performance. Balance. Longevity.', []),
|
||||
T('home.training.title', 'Training'),
|
||||
T('home.training.subtitle', 'Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.'),
|
||||
|
||||
// --- Home: hero ---
|
||||
T('home.hero.title-line-1', 'PERFORMANCE.'),
|
||||
T('home.hero.title-line-2', 'BALANCE.'),
|
||||
T('home.hero.title-line-3', 'LONGEVITY.'),
|
||||
T('home.hero.cta', 'Scopri'),
|
||||
I('home.hero.image'),
|
||||
|
||||
// --- Home: info columns ---
|
||||
T('home.info.col1.title', 'PERFORMANCE.'),
|
||||
T('home.info.col2.title', 'BALANCE.'),
|
||||
T('home.info.col3.title', 'LONGEVITY.'),
|
||||
I('home.info.col1.image'),
|
||||
I('home.info.col2.image'),
|
||||
I('home.info.col3.image'),
|
||||
|
||||
// --- Home: feature blocks ---
|
||||
T('home.feature.performance.title', 'PERFORMANCE'),
|
||||
T('home.feature.performance.body', 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.'),
|
||||
T('home.feature.performance.cta', 'Scopri'),
|
||||
T('home.feature.balance.title', 'BALANCE'),
|
||||
T('home.feature.balance.body', 'L’equilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.'),
|
||||
T('home.feature.balance.cta', 'Scopri'),
|
||||
T('home.feature.longevity.title', 'LONGEVITY'),
|
||||
T('home.feature.longevity.body', 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.'),
|
||||
T('home.feature.longevity.cta', 'Scopri'),
|
||||
I('home.feature.performance.image'),
|
||||
I('home.feature.balance.image'),
|
||||
I('home.feature.longevity.image'),
|
||||
|
||||
// --- Home: quote banner ---
|
||||
H('home.quote.text', '<strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.'),
|
||||
T('home.quote.cite', 'Insanitylab', []),
|
||||
I('home.quote.image'),
|
||||
|
||||
// --- Home: training cards (data-driven da trainings.ts) ---
|
||||
T('home.training-cards.link-label', 'Scopri', []),
|
||||
...trainings.flatMap((tr) => [
|
||||
T(`training.${tr.id}.title`, tr.title),
|
||||
T(`training.${tr.id}.caption`, tr.caption),
|
||||
T(`training.${tr.id}.description`, tr.description),
|
||||
T(`training.${tr.id}.cta-label`, tr.cta.label, []),
|
||||
I(`training.${tr.id}.image`),
|
||||
]),
|
||||
|
||||
// --- Home: servizi ---
|
||||
T('home.services.title', 'Servizi'),
|
||||
T('home.services.body', 'Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'),
|
||||
|
||||
// --- Home: team (data-driven da team.ts) ---
|
||||
H('home.team.title', 'Team<br />Trainer'),
|
||||
T('home.team.cta', 'Scopri team', []),
|
||||
...team.flatMap((m) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return [
|
||||
T(`team.${slug}.name`, m.name, []),
|
||||
T(`team.${slug}.short-name`, m.shortName),
|
||||
T(`team.${slug}.role`, m.role, []),
|
||||
T(`team.${slug}.bio`, m.bio),
|
||||
I(`team.${slug}.image`),
|
||||
I(`team.${slug}.image-about`),
|
||||
];
|
||||
}),
|
||||
|
||||
// --- Home: metodo (data-driven da method.ts) ---
|
||||
H('home.method.title', 'Il metodo<br />InsanityLab'),
|
||||
T('home.method.cta', 'Contattaci', []),
|
||||
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
|
||||
T('method.extra-note', methodExtraNote, []),
|
||||
...methodSteps.flatMap((s) => [
|
||||
T(`method.step.${s.n}.title`, s.title),
|
||||
T(`method.step.${s.n}.text`, s.text),
|
||||
I(`method.step.${s.n}.image`),
|
||||
]),
|
||||
|
||||
// --- Home: partner (data-driven da partners.ts) ---
|
||||
...partners.flatMap((p, i) => [
|
||||
T(`partner.${i + 1}.name`, p.name, []),
|
||||
I(`partner.${i + 1}.image`),
|
||||
]),
|
||||
|
||||
// --- Agenda (scope limitato: titolo, discipline, giorni, slot — celle escluse) ---
|
||||
T('agenda.title', 'Agenda'),
|
||||
...disciplines.map((d, i) => T(`agenda.discipline.${i + 1}`, d, [])),
|
||||
...days.map((d, i) => T(`agenda.day.${i + 1}`, d, [])),
|
||||
...slots.map((s, i) => T(`agenda.slot.${i + 1}`, s, [])),
|
||||
|
||||
// --- About: meta, hero ---
|
||||
T('about.meta.title', 'Insanitylab', []),
|
||||
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
|
||||
T('about.hero.title', 'Insanitylab', []),
|
||||
|
||||
// --- About: mission ---
|
||||
T('about.mission.title', 'Mission'),
|
||||
T('about.mission.body', 'In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.'),
|
||||
I('about.mission.image'),
|
||||
|
||||
// --- About: direzione + approccio ---
|
||||
T('about.direzione.title', 'Direzione'),
|
||||
T('about.direzione.body', 'Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.'),
|
||||
T('about.direzione.cta', 'Scopri'),
|
||||
T('about.approccio.title', 'Approccio'),
|
||||
T('about.approccio.body', 'InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.'),
|
||||
T('about.approccio.cta', 'Contattaci'),
|
||||
|
||||
// --- About: about ---
|
||||
T('about.about.title', 'About'),
|
||||
T('about.about.body', "InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."),
|
||||
I('about.about.image'),
|
||||
|
||||
// --- About: convenzioni ---
|
||||
T('about.convenzioni.title', 'Convenzioni'),
|
||||
T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."),
|
||||
T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'),
|
||||
T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'),
|
||||
T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."),
|
||||
T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
|
||||
I('about.convenzioni.image'),
|
||||
|
||||
// --- About: team ---
|
||||
T('about.team.title', 'Team'),
|
||||
T('about.team.body', 'Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.'),
|
||||
T('about.team.staff-intro', 'Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:'),
|
||||
|
||||
// --- About: metodo ---
|
||||
T('about.metodo.title', 'Metodo'),
|
||||
T('about.metodo.body', 'Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.'),
|
||||
T('about.metodo.closing', 'In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.'),
|
||||
I('about.metodo.image'),
|
||||
|
||||
// --- About: studios ---
|
||||
T('about.studios.title', 'In-Sanity Studios'),
|
||||
T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."),
|
||||
T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."),
|
||||
T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'),
|
||||
I('about.studios.image'),
|
||||
|
||||
// --- About: vision ---
|
||||
T('about.vision.title', 'Vision'),
|
||||
T('about.vision.body1', "InsanityLab nasce con l'obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l'estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita."),
|
||||
T('about.vision.body2', "L'obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità."),
|
||||
I('about.vision.image'),
|
||||
|
||||
// --- About: storylab + timeline (Timeline.astro) ---
|
||||
T('about.storylab.title', 'Storylab'),
|
||||
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
|
||||
T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
|
||||
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come 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.stage1.label', 'INSANITYLAB 2025', []),
|
||||
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
|
||||
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
|
||||
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
// --- Services: lista (/services) ---
|
||||
T('services.meta.title', 'Servizi', []),
|
||||
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []),
|
||||
T('services.hero.title', 'Servizi', []),
|
||||
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
|
||||
T('services.card.more-label', 'Per saperne di più →', []),
|
||||
|
||||
// --- Services: dettaglio (data-driven da services.ts) ---
|
||||
...services.flatMap((p) => [
|
||||
T(`service.${p.slug}.title`, p.title),
|
||||
T(`service.${p.slug}.subtitle`, p.subtitle),
|
||||
T(`service.${p.slug}.excerpt`, p.excerpt),
|
||||
T(`service.${p.slug}.long-description`, p.longDescription),
|
||||
...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
|
||||
...p.faq.flatMap((item, i) => [
|
||||
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
|
||||
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
|
||||
]),
|
||||
I(`service.${p.slug}.image`),
|
||||
]),
|
||||
|
||||
// --- Services: dettaglio, template ---
|
||||
T('service.side.categories-label', 'Categorie'),
|
||||
T('service.side.category.1', 'Fitness', []),
|
||||
T('service.side.category.2', 'Salute', []),
|
||||
T('service.side.category.3', 'Stile di vita', []),
|
||||
T('service.side.category.4', 'Nutrizione', []),
|
||||
T('service.side.others-label', 'Altri servizi'),
|
||||
H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
|
||||
T('service.pricing.per', 'al mese, semestrale', []),
|
||||
T('service.pricing.entries-singular', 'ingresso settimanale', []),
|
||||
T('service.pricing.entries-plural', 'ingressi settimanali', []),
|
||||
T('service.pricing.buy-cta', 'Acquista', []),
|
||||
|
||||
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
|
||||
T('pricing.heading', performanceClassPricing.heading),
|
||||
T('pricing.intro', performanceClassPricing.intro),
|
||||
...performanceClassPricing.groups.flatMap((g, gi) => [
|
||||
T(`pricing.group.${gi + 1}.label`, g.label),
|
||||
...g.plans.flatMap((plan, pi) => [
|
||||
T(`pricing.group.${gi + 1}.plan.${pi + 1}.note`, plan.note, []),
|
||||
T(`pricing.group.${gi + 1}.plan.${pi + 1}.installments`, plan.installments, []),
|
||||
T(`pricing.group.${gi + 1}.plan.${pi + 1}.best`, plan.best, []),
|
||||
]),
|
||||
]),
|
||||
|
||||
// --- Training: meta, hero, intro, trial ---
|
||||
T('training.meta.title', 'Training', []),
|
||||
T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
|
||||
T('training.hero.title', 'training', []),
|
||||
T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'),
|
||||
|
||||
// --- Contact ---
|
||||
T('contact.meta.title', 'Contatti', []),
|
||||
T('contact.meta.description', 'Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.', []),
|
||||
T('contact.hero.title', 'Contattaci', []),
|
||||
I('contact.hero.image'),
|
||||
T('contact.map.button', 'Carica la mappa (Google Maps)', []),
|
||||
T('contact.map.note', 'La mappa viene caricata da Google solo dopo il tuo consenso.', []),
|
||||
T('contact.info.address-label', 'Indirizzo'),
|
||||
T('contact.info.phone-label', 'Telefono'),
|
||||
T('contact.info.email-label', 'E-mail'),
|
||||
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
|
||||
|
||||
// --- 404 ---
|
||||
T('notfound.meta.title', 'Pagina non trovata', []),
|
||||
T('notfound.eyebrow', 'Errore 404', []),
|
||||
T('notfound.title', 'Pagina non trovata'),
|
||||
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
|
||||
T('notfound.cta', 'Torna alla home', []),
|
||||
|
||||
// --- Promo ed eventi (promo-eventi.astro) ---
|
||||
// Hero
|
||||
T('promo.hero.badge', 'insanity.lab · bologna savena', []),
|
||||
T('promo.hero.title1', 'Allenati con metodo.'),
|
||||
T('promo.hero.accent', 'Scegli come'),
|
||||
T('promo.hero.title2', 'vuoi farlo.'),
|
||||
H('promo.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
|
||||
T('promo.hero.cta', 'LE NOSTRE PROPOSTE →', []),
|
||||
// Launch
|
||||
T('promo.launch.badge', 'solo per apertura nuova sede Settembre 2026', []),
|
||||
T('promo.launch.title', 'Il nostro metodo nasce per durare.'),
|
||||
T('promo.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
|
||||
T('promo.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte."),
|
||||
// Perché 5
|
||||
T('promo.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
|
||||
// Label bottone acquisto (condivisa dalle 4 card)
|
||||
T('promo.buy-label', 'ACQUISTA →', []),
|
||||
// Card 1 — One to One
|
||||
T('promo.card.1.eyebrow', 'Personal training', []),
|
||||
T('promo.card.1.title', 'One to One'),
|
||||
T('promo.card.1.price', '163 €', []),
|
||||
T('promo.card.1.price-old', '300 €', []),
|
||||
T('promo.card.1.unit', '5 incontri', []),
|
||||
T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||
T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
||||
T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
|
||||
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||
// Card 2 — Small Group (in evidenza)
|
||||
T('promo.card.2.badge', 'Più scelto', []),
|
||||
T('promo.card.2.eyebrow', 'Personal training', []),
|
||||
T('promo.card.2.title', 'Small Group'),
|
||||
T('promo.card.2.price', '110 €', []),
|
||||
T('promo.card.2.price-old', '220 €', []),
|
||||
T('promo.card.2.unit', '5 incontri', []),
|
||||
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||
T('promo.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
||||
T('promo.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
|
||||
T('promo.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||
// Card 3 — Reformer
|
||||
T('promo.card.3.eyebrow', 'Pilates class', []),
|
||||
T('promo.card.3.title', 'Reformer'),
|
||||
T('promo.card.3.price', '100 €', []),
|
||||
T('promo.card.3.price-old', '140 €', []),
|
||||
T('promo.card.3.unit', '5 incontri', []),
|
||||
T('promo.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
|
||||
T('promo.card.3.feature.2', 'Lavoro a basso impatto sulle articolazioni'),
|
||||
T('promo.card.3.feature.3', 'Percorso breve, non lezioni isolate'),
|
||||
// Card 4 — Holistic class
|
||||
T('promo.card.4.eyebrow', 'Holistic class', []),
|
||||
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
|
||||
T('promo.card.4.price', '50 €', []),
|
||||
T('promo.card.4.price-old', '100 €', []),
|
||||
T('promo.card.4.unit', '5 incontri', []),
|
||||
T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
|
||||
T('promo.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
|
||||
T('promo.card.4.feature.3', 'Adatto a tutti i livelli'),
|
||||
// Nota validità
|
||||
T('promo.validity-note', 'Disponibile solo per l\'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
|
||||
// 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.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'),
|
||||
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.'),
|
||||
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.'),
|
||||
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.'),
|
||||
T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
|
||||
H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
|
||||
T('promo.faq.5.q', 'Il pagamento è sicuro?'),
|
||||
H('promo.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
|
||||
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
|
||||
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
|
||||
|
||||
];
|
||||
|
||||
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
|
||||
@@ -2,8 +2,8 @@ export const methodIntro = "Il Metodo Insanitylab si basa su un percorso struttu
|
||||
export const methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard.";
|
||||
|
||||
export const methodSteps = [
|
||||
{ n: 1, title: "Prima consulenza conoscitiva iniziale", text: "Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.", extra: false, image: "method-step-1" },
|
||||
{ n: 2, title: "Prima seduta operativa", text: "In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.", extra: false, image: "method-step-2" },
|
||||
{ n: 3, title: "Plicometria", text: "Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.", extra: true, image: "method-step-3" },
|
||||
{ n: 4, title: "Analisi antropometriche", text: "Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.", extra: true, image: "method-step-4" },
|
||||
{ n: 1, title: "Prima consulenza conoscitiva iniziale", text: "La base di partenza per definire le priorità del percorso di ciascun cliente.", extra: false, image: "method-step-1" },
|
||||
{ n: 2, title: "Check-up fisico", text: "Profilazione fisica per definire lo stato di salute iniziale del cliente.", extra: false, image: "method-step-2" },
|
||||
{ n: 3, title: "Insanity plan", text: "Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.", extra: true, image: "method-step-3" },
|
||||
{ n: 4, title: "Monitoraggio costante", text: "Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.", extra: true, image: "method-step-4" },
|
||||
];
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
export interface Program {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
excerpt: string;
|
||||
longDescription: string;
|
||||
features: string[];
|
||||
faq: { q: string; a: string }[];
|
||||
hasPricing: boolean;
|
||||
}
|
||||
|
||||
export const programs: Program[] = [
|
||||
{
|
||||
slug: "personal-training",
|
||||
title: "Personal Training",
|
||||
subtitle: "One to One / Small Group",
|
||||
excerpt: "Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
|
||||
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.",
|
||||
features: ["Trainer dedicato", "Percorso su misura", "Valutazione iniziale e monitoraggio", "Formula one to one o small group (max 3)"],
|
||||
faq: [
|
||||
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "performance-class",
|
||||
title: "Performance Class",
|
||||
subtitle: "Gruppo max 8 persone",
|
||||
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
||||
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
||||
features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
|
||||
faq: [
|
||||
{ q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio." },
|
||||
{ q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
|
||||
{ q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
|
||||
{ q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
|
||||
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
|
||||
{ q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
|
||||
],
|
||||
hasPricing: true,
|
||||
},
|
||||
{
|
||||
slug: "coaching",
|
||||
title: "Coaching",
|
||||
subtitle: "Programma di allenamento e monitoraggio",
|
||||
excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.",
|
||||
longDescription: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).",
|
||||
features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
|
||||
faq: [
|
||||
{ q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
|
||||
{ q: "Serve attrezzatura?", a: "Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa." },
|
||||
{ q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "pilates-flow",
|
||||
title: "Pilates Flow",
|
||||
subtitle: "Movimento e controllo",
|
||||
excerpt: "Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre l’osteopatia supporta recupero, prevenzione e benessere globale integrandosi con l’allenamento.",
|
||||
longDescription: "Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con l’osteopatia, supporta recupero, prevenzione e benessere globale all’interno del percorso di allenamento.",
|
||||
features: ["Postura e core", "Movimento fluido e controllo", "Integrazione con osteopatia", "Benessere globale"],
|
||||
faq: [
|
||||
{ q: "A chi è adatto il Pilates Flow?", a: "A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello." },
|
||||
{ q: "Serve esperienza precedente?", a: "No: le progressioni sono graduali e seguite da professionisti qualificati." },
|
||||
{ q: "Come si integra con l’osteopatia?", a: "Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "rehab",
|
||||
title: "Rehab",
|
||||
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
|
||||
excerpt: "Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno all’attività fisica in modo sicuro, progressivo e personalizzato.",
|
||||
longDescription: "Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno all’attività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.",
|
||||
features: ["Recupero post-infortunio", "Patologie muscolo-scheletriche", "Supervisione di chinesiologi e osteopati", "Progressione sicura e personalizzata"],
|
||||
faq: [
|
||||
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con l’area osteopatica." },
|
||||
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
|
||||
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "nutrition",
|
||||
title: "Nutrizione",
|
||||
subtitle: "Alimentazione e stile di vita",
|
||||
excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
|
||||
longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
|
||||
features: ["Piani alimentari su misura", "Integrazione con l’allenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
|
||||
faq: [
|
||||
{ q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
|
||||
{ q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
|
||||
{ q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function getProgram(slug: string): Program | undefined {
|
||||
return programs.find((p) => p.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
|
||||
export const serviceIcons: Record<string, string> = {
|
||||
'one-to-one': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'small-group': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
|
||||
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
|
||||
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
|
||||
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
|
||||
// Servizi rinominati (modifiche 02): profilazione (grafico/analisi), osteopatia (mani)
|
||||
'profilazione': 'M4 20V6m5 14V4m5 16v-8m5 8V9M3 20h18',
|
||||
'osteopatia': 'M8 3v7a4 4 0 008 0V3M6 14c0 4 3 7 6 7s6-3 6-7',
|
||||
// Riquadri Training con pagina dettaglio
|
||||
'holistic-class': 'M12 3a3 3 0 110 6 3 3 0 010-6zm0 6v7m-4 3l4-3 4 3',
|
||||
'reformer-class': 'M3 8h18M3 16h18M6 8v8m12-8v8M9 12h6',
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
export interface Service {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
excerpt: string;
|
||||
longDescription: string;
|
||||
features: string[];
|
||||
faq: { q: string; a: string }[];
|
||||
hasPricing: boolean;
|
||||
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
|
||||
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
|
||||
grid?: boolean;
|
||||
}
|
||||
|
||||
export const services: Service[] = [
|
||||
{
|
||||
slug: "one-to-one",
|
||||
title: "One to One",
|
||||
subtitle: "Allenamento con un trainer dedicato",
|
||||
excerpt: "L’allenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
|
||||
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "small-group",
|
||||
title: "Small Group",
|
||||
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
|
||||
excerpt: "Lo Small Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.",
|
||||
longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. 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.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Quante persone ci sono in un Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
slug: "performance-class",
|
||||
title: "Performance Class",
|
||||
subtitle: "Gruppo max 8 persone",
|
||||
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
||||
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
||||
features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
|
||||
faq: [
|
||||
{ q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio." },
|
||||
{ q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
|
||||
{ q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
|
||||
{ q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
|
||||
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
|
||||
{ q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
|
||||
],
|
||||
hasPricing: true,
|
||||
},
|
||||
{
|
||||
slug: "coaching",
|
||||
title: "Coaching",
|
||||
subtitle: "Programma di allenamento e monitoraggio",
|
||||
excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.",
|
||||
longDescription: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).",
|
||||
features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
|
||||
faq: [
|
||||
{ q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
|
||||
{ q: "Serve attrezzatura?", a: "Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa." },
|
||||
{ q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
|
||||
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
|
||||
slug: "profilazione",
|
||||
title: "Profilazione",
|
||||
subtitle: "Check-up e valutazione fisica",
|
||||
excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.",
|
||||
longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.",
|
||||
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
|
||||
faq: [
|
||||
{ q: "Quando si fa la profilazione?", a: "All’inizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
|
||||
{ q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
|
||||
slug: "osteopatia",
|
||||
title: "Osteopatia",
|
||||
subtitle: "Approccio manuale globale non invasivo",
|
||||
excerpt: "L’osteopatia è una delle colonne portanti dell’approccio integrato di InsanityLab. Non è un servizio aggiunto: è parte della visione.",
|
||||
longDescription: "L’osteopatia è indicata sia in modo preventivo, per chi vuole mantenere il proprio equilibrio corporeo, sia in modo curativo, per chi sta affrontando un problema specifico. È particolarmente utile in sinergia con l’allenamento, perché permette di lavorare su compensi e rigidità che altrimenti limiterebbero i progressi. Nel nostro approccio rientrano anche l’osteopatia neonatale e pediatrica.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "L’osteopatia è preventiva o curativa?", a: "Entrambe: è utile sia per mantenere l’equilibrio corporeo sia per affrontare un problema specifico, in sinergia con l’allenamento." },
|
||||
{ q: "Trattate anche neonati e bambini?", a: "Sì, il nostro approccio comprende anche l’osteopatia neonatale e pediatrica." },
|
||||
{ q: "Serve una prescrizione medica?", a: "Non è necessaria; se disponibile, eventuale documentazione clinica è utile alla prima valutazione." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "nutrition",
|
||||
title: "Nutrizione",
|
||||
subtitle: "Alimentazione e stile di vita",
|
||||
excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
|
||||
longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
|
||||
features: ["Piani alimentari su misura", "Integrazione con l’allenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
|
||||
faq: [
|
||||
{ q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
|
||||
{ q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
|
||||
{ q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
// Approfondimento del riquadro Training "Rehab" (fuori dalla griglia /services).
|
||||
slug: "rehab",
|
||||
title: "Rehab",
|
||||
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
|
||||
excerpt: "Accompagniamo il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena.",
|
||||
longDescription: "Il Rehab InsanityLab significa accompagnare il corpo nel recupero della sua funzionalità attraverso il movimento guidato. Non è fisioterapia in senso clinico: è il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento. Il servizio lavora con un ampio spettro di condizioni e ogni situazione viene valutata singolarmente con metodologie evidence based.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con medici specialisti, fisioterapisti e osteopati." },
|
||||
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
|
||||
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
// Approfondimento del riquadro Training "Holistic Class" (ex Pilates Flow / Performance Class).
|
||||
slug: "holistic-class",
|
||||
title: "Holistic Class",
|
||||
subtitle: "Classi fino a 8 persone con trainer certificato",
|
||||
excerpt: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti.",
|
||||
longDescription: "La Holistic Class lavora su mobilità articolare, respirazione ed equilibrio. Puoi seguirla da sola o integrarla nei tuoi allenamenti; è adatta a tutti i livelli e ogni sessione dura 40 minuti. Comprende lavoro di Pilates Flow, Mobility e Yoga.",
|
||||
features: ["Pilates Flow", "Mobility", "Yoga"],
|
||||
faq: [
|
||||
{ q: "A chi è adatta la Holistic Class?", a: "A chiunque voglia migliorare mobilità, respirazione ed equilibrio, a ogni livello di partenza." },
|
||||
{ q: "Posso integrarla con altri allenamenti?", a: "Sì: funziona sia come percorso a sé sia come complemento al tuo programma di allenamento." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
// Approfondimento del riquadro Training "Reformer Class" (nuova).
|
||||
slug: "reformer-class",
|
||||
title: "Reformer Class",
|
||||
subtitle: "Classi fino a 5 persone con macchinari reformer",
|
||||
excerpt: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti.",
|
||||
longDescription: "La Reformer Class utilizza i macchinari reformer per il pilates in classi fino a 5 persone. Il lavoro è dedicato a core, postura e mobilità, a basso impatto sulle articolazioni e adatto a tutti i livelli. Ogni lezione dura 40 minuti.",
|
||||
features: ["Macchinari reformer", "Core, postura e mobilità", "Lavoro a basso impatto sulle articolazioni", "Classi fino a 5 persone", "Lezioni da 40 minuti"],
|
||||
faq: [
|
||||
{ q: "Serve esperienza con il reformer?", a: "No: le progressioni sono graduali e seguite da trainer certificati, adatte anche a chi inizia." },
|
||||
{ q: "Quante persone per classe?", a: "Massimo 5, per garantire attenzione e correzione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function getService(slug: string): Service | undefined {
|
||||
return services.find((p) => p.slug === slug);
|
||||
}
|
||||
@@ -2,33 +2,37 @@ export const site = {
|
||||
name: "InsanityLab",
|
||||
phone: "351 7535977",
|
||||
email: "info@insanitylab.it",
|
||||
address: "Via Leandro Alberti, 76",
|
||||
// Due ingressi dello stesso stabile. address = testo mostrato; mapQuery = punto geolocalizzabile per Google Maps.
|
||||
address: "Via Leandro Alberti 76 e Via De Mattiolo 5",
|
||||
cityLine: "40139 Bologna",
|
||||
mapQuery: "IN-SANITY LAB, Via Leandro Alberti 76, 40139 Bologna",
|
||||
hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 19.00 · Dom chiuso"],
|
||||
socials: [
|
||||
{ label: "Instagram", url: "https://www.instagram.com/insanitylab" },
|
||||
{ label: "Facebook", url: "https://www.facebook.com/insanitylab" },
|
||||
{ label: "LinkedIn", url: "https://www.linkedin.com/company/insanitylab" },
|
||||
{ label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
|
||||
{ label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
|
||||
],
|
||||
navigation: [
|
||||
{ label: "Insanitylab", href: "/about" },
|
||||
{ label: "Training", href: "/training" },
|
||||
{ label: "Programmi", href: "/programs" },
|
||||
{ label: "Servizi", href: "/services" },
|
||||
{ label: "About us", href: "/about" },
|
||||
{ label: "Blog & Edugo", href: "/blog" },
|
||||
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
],
|
||||
footerTraining: [
|
||||
{ label: "One to one", href: "/training#one-to-one" },
|
||||
{ label: "Small groups", href: "/training#small-groups" },
|
||||
{ label: "Performance class", href: "/training#performance-class" },
|
||||
{ label: "Fit remote", href: "/training#fit-remote" },
|
||||
{ label: "Small group", href: "/training#small-groups" },
|
||||
{ label: "Rehab", href: "/training#rehab" },
|
||||
{ label: "Holistic class", href: "/training#holistic-class" },
|
||||
{ label: "Reformer class", href: "/training#reformer-class" },
|
||||
{ label: "Coaching", href: "/training#coaching" },
|
||||
],
|
||||
footerPrograms: [
|
||||
{ label: "Personal Training", href: "/programs/personal-training" },
|
||||
{ label: "Performance Class", href: "/programs/performance-class" },
|
||||
{ label: "Coaching", href: "/programs/coaching" },
|
||||
{ label: "Rehab", href: "/programs/rehab" },
|
||||
{ label: "Pilates Flow", href: "/programs/pilates-flow" },
|
||||
{ label: "Nutrizione", href: "/programs/nutrition" },
|
||||
footerServices: [
|
||||
{ label: "Personal Training", href: "/services/personal-training" },
|
||||
{ label: "Performance Class", href: "/services/performance-class" },
|
||||
{ label: "Coaching", href: "/services/coaching" },
|
||||
{ label: "Profilazione", href: "/services/profilazione" },
|
||||
{ label: "Osteopatia", href: "/services/osteopatia" },
|
||||
{ label: "Nutrizione", href: "/services/nutrition" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; founder: boolean }
|
||||
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; imageAbout?: string; founder: boolean }
|
||||
|
||||
export const team: Member[] = [
|
||||
{ name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, image: "team-donata",
|
||||
{ name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, imageAbout: "team-donata-dark", image: "team-donata",
|
||||
bio: "Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona l’area dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza." },
|
||||
{ name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, image: "team-nicola",
|
||||
{ name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, imageAbout: "team-nicola-dark", image: "team-nicola",
|
||||
bio: "Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e all’allenamento avanzato." },
|
||||
{ name: "Eva Cassetta", shortName: "Eva", role: "Business Manager – Balance & Strategy", founder: true,
|
||||
{ name: "Eva Cassetta", shortName: "Eva", role: "Business Manager – Balance & Strategy", founder: true, imageAbout: "team-eva-dark",
|
||||
bio: "Eva Cassetta ricopre il ruolo di Business Manager – Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo." },
|
||||
{ name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer – Functional & Movement", founder: false,
|
||||
{ name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-alessandro-dark",
|
||||
bio: "Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." },
|
||||
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, image: "team-eleonora",
|
||||
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, imageAbout: "team-eleonora-dark", image: "team-eleonora",
|
||||
bio: "Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
|
||||
{ name: "Antonello D’Alessandro", shortName: "Antonello", role: "Trainer – Exercise & Health", founder: false, image: "team-antonello",
|
||||
bio: "Trainer – Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia." },
|
||||
{ name: "Alice Roda", shortName: "Alice", role: "Trainer – Functional & Movement", founder: false,
|
||||
{ name: "Alice Roda", shortName: "Alice", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-alice-dark",
|
||||
bio: "Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso." },
|
||||
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false,
|
||||
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-davide-dark",
|
||||
bio: "Trainer – Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature." },
|
||||
{ name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer – Pilates, Yoga & Mindfulness", founder: false,
|
||||
{ name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer – Pilates, Yoga & Mindfulness", founder: false, imageAbout: "team-giulia-dark",
|
||||
bio: "Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo." },
|
||||
];
|
||||
|
||||
export const homeTeam = ["Donata", "Nicola", "Eleonora", "Antonello"];
|
||||
export const homeTeam = ["Donata", "Nicola", "Eleonora"];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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: "Contattaci", href: "/contact" } },
|
||||
{ id: "small-groups", title: "Small Groups", caption: "Mini gruppi (max 3 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 3 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: "Contattaci", href: "/contact" } },
|
||||
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/programs/performance-class" } },
|
||||
{ id: "fit-remote", title: "Fit Remote", caption: "Piano di allenamento su misura, ovunque ti alleni.", image: "training-fit-remote", description: "Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.", cta: { label: "Contattaci", href: "/contact" } },
|
||||
{ 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: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } },
|
||||
{ id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } },
|
||||
{ id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } },
|
||||
{ id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } },
|
||||
] as const;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user?: { id: number; username: string };
|
||||
user?: { id: number; username: string; role: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,26 @@ const user = Astro.locals.user;
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
|
||||
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
|
||||
.pill--draft { background: #f3d9a4; }
|
||||
.form-msg { font-size: .9rem; margin-top: 8px; }
|
||||
.form-msg--ok { color: #3c7a3c; }
|
||||
.form-msg--err { color: #b03030; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="abar">
|
||||
<strong>InsanityLab · Admin</strong>
|
||||
<span>
|
||||
{user && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
|
||||
{user && (
|
||||
<>
|
||||
{`Ciao, ${user.username}`}
|
||||
<a href="/admin">Articoli</a>
|
||||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
|
||||
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
|
||||
<a href="/admin/new">Nuovo</a>
|
||||
<a href="/admin/logout">Esci</a>
|
||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="awrap"><slot /></div>
|
||||
|
||||
@@ -8,13 +8,28 @@ import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { getDb } from '../lib/db';
|
||||
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
||||
|
||||
interface Props { title: string; description?: string }
|
||||
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
|
||||
|
||||
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
const user = tok ? getSessionUser(getDb(), tok) : null;
|
||||
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
|
||||
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
|
||||
const showTags = canTags && wantTags;
|
||||
|
||||
// Google Tag Manager: l'ID container arriva da GTM_ID (.env), letto a runtime via process.env
|
||||
// perché in SSR le PUBLIC_* di Vite verrebbero inlinate a build-time. Se vuoto GTM non carica.
|
||||
const gtmId = process.env.GTM_ID;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
{gtmId && (
|
||||
<script is:inline set:html={`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtmId}');`} />
|
||||
)}
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — InsanityLab</title>
|
||||
@@ -24,11 +39,14 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="sitemap" href="/sitemap.xml" />
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
{gtmId && (
|
||||
<noscript set:html={`<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
|
||||
)}
|
||||
<Header user={user} showTags={showTags} canTags={canTags} />
|
||||
<main><slot /></main>
|
||||
<Footer />
|
||||
<script>
|
||||
@@ -65,5 +83,257 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
|
||||
update();
|
||||
}
|
||||
</script>
|
||||
{showTags && (
|
||||
<style is:global>
|
||||
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
|
||||
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
|
||||
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; border-radius: 3px; }
|
||||
.tag-badge:hover { opacity: 1; }
|
||||
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; border-radius: 2px; }
|
||||
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(28,20,16,.55); backdrop-filter: blur(2px);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.tag-modal[hidden] { display: none; }
|
||||
.tag-modal__box { background: #fdfbf7; color: #2b2320; width: min(600px, 94vw); max-height: 88vh; overflow: auto;
|
||||
border-radius: 14px; box-shadow: 0 24px 60px rgba(28,20,16,.35); font-family: system-ui, sans-serif; }
|
||||
.tag-modal__head { padding: 20px 24px 16px; border-bottom: 1px solid #ece4d8; }
|
||||
.tag-modal__head h3 { margin: 0; font: 600 1.05rem/1.3 system-ui, sans-serif; letter-spacing: .01em; }
|
||||
.tag-modal__head .tm-tagchip { display: inline-block; margin-top: 8px; background: #efe8dc; color: #6b5b48;
|
||||
font: 500 11px/1.6 ui-monospace, monospace; padding: 2px 9px; border-radius: 999px; word-break: break-all; }
|
||||
.tag-modal__head .guid { font: 10px/1.5 ui-monospace, monospace; color: #a99f90; margin: 6px 0 0; }
|
||||
.tag-modal__body { padding: 20px 24px; }
|
||||
.tag-modal__box textarea { width: 100%; min-height: 150px; font: inherit; font-size: .95rem; line-height: 1.5;
|
||||
padding: 12px 14px; box-sizing: border-box; border: 1px solid #dcd2c4; border-radius: 10px; background: #fff;
|
||||
resize: vertical; color: #2b2320; }
|
||||
.tag-modal__box textarea:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
|
||||
.tm-styles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px 14px; margin-top: 18px; }
|
||||
.tm-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.tm-field__label { font: 600 11px/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: .08em; color: #8a7c68; }
|
||||
.tm-field select { appearance: none; width: 100%; padding: 9px 30px 9px 12px; border: 1px solid #dcd2c4; border-radius: 9px;
|
||||
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%238a7c68' stroke-width='1.6'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E") no-repeat right 11px center;
|
||||
font: inherit; font-size: .9rem; color: #2b2320; cursor: pointer; }
|
||||
.tm-field select:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
|
||||
.tm-field select.is-set { border-color: #9c8b70; background-color: #f6f1e8; font-weight: 600; }
|
||||
.tm-field select.is-estimated { border-style: dashed; border-color: #c4b79f; color: #6b5b48; }
|
||||
.tm-note { margin: 12px 0 0; font: 400 12px/1.5 system-ui, sans-serif; color: #8a7c68; }
|
||||
.tag-modal__box input[type=file] { display: block; width: 100%; font: inherit; font-size: .9rem; padding: 10px;
|
||||
border: 1px dashed #cbbfad; border-radius: 10px; background: #fff; box-sizing: border-box; }
|
||||
.tm-del { margin-top: 12px; padding: 8px 14px; border: 0; border-radius: 8px; background: #b4443a; color: #fff; cursor: pointer; font: 600 .85rem system-ui, sans-serif; }
|
||||
.tag-modal__msg { color: #b4443a; margin: 12px 24px 0; min-height: 18px; font-size: .88rem; }
|
||||
.tag-modal__row { display: flex; gap: 10px; justify-content: flex-end; padding: 16px 24px 22px; }
|
||||
.tag-modal__row button { padding: 10px 20px; border: 0; border-radius: 9px; cursor: pointer; font: 600 .9rem system-ui, sans-serif; }
|
||||
.tag-modal__save { background: #3a2d26; color: #fff; }
|
||||
.tag-modal__save:hover { background: #2b211b; }
|
||||
.tag-modal__cancel { background: #ece4d8; color: #5b4f42; }
|
||||
.tag-modal__cancel:hover { background: #e0d6c6; }
|
||||
</style>
|
||||
<div class="tag-modal" id="tag-modal" hidden>
|
||||
<div class="tag-modal__box">
|
||||
<div class="tag-modal__head">
|
||||
<h3>Modifica contenuto</h3>
|
||||
<span class="tm-tagchip" id="tm-tag"></span>
|
||||
<p class="guid" id="tm-guid"></p>
|
||||
</div>
|
||||
<div class="tag-modal__body" id="tm-body"></div>
|
||||
<div class="tag-modal__msg" id="tm-msg"></div>
|
||||
<div class="tag-modal__row">
|
||||
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
|
||||
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const STYLE_GROUPS = { size: ['s','m','l','xl','xxl'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right','justify'] };
|
||||
const GROUP_LABELS = { size: 'Dimensione', color: 'Colore', weight: 'Peso', style: 'Stile', align: 'Allineamento' };
|
||||
const VALUE_LABELS = {
|
||||
size: { s: 'S', m: 'M', l: 'L', xl: 'XL', xxl: 'XXL' },
|
||||
weight: { normal: 'Normale', bold: 'Grassetto' },
|
||||
style: { normal: 'Normale', italic: 'Corsivo' },
|
||||
align: { left: 'Sinistra', center: 'Centro', right: 'Destra', justify: 'Giustificato' },
|
||||
color: { accent: 'Accento', 'accent-dark': 'Accento scuro', dark: 'Scuro', heading: 'Titolo', text: 'Testo', 'text-light': 'Testo chiaro', white: 'Bianco' },
|
||||
};
|
||||
const valLabel = (g, v) => (VALUE_LABELS[g] && VALUE_LABELS[g][v]) || v;
|
||||
|
||||
// Stima il preset più vicino all'aspetto REALE dell'elemento (getComputedStyle).
|
||||
const px = (s) => parseFloat(s) || 0;
|
||||
const rgb = (s) => { const m = String(s).match(/-?\d+(\.\d+)?/g); return m ? m.slice(0, 3).map(Number) : null; };
|
||||
const SIZE_SCALE = { s: 0.85, m: 1, l: 1.25, xl: 1.5, xxl: 2 };
|
||||
let colorProbe = null;
|
||||
function nearestSize(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
const ref = el.parentElement ? px(getComputedStyle(el.parentElement).fontSize) : px(cs.fontSize);
|
||||
const ratio = ref ? px(cs.fontSize) / ref : 1;
|
||||
let best = 'm', bd = Infinity;
|
||||
for (const k in SIZE_SCALE) { const d = Math.abs(SIZE_SCALE[k] - ratio); if (d < bd) { bd = d; best = k; } }
|
||||
return best;
|
||||
}
|
||||
function nearestColor(el) {
|
||||
const a = rgb(getComputedStyle(el).color); if (!a) return null;
|
||||
if (!colorProbe) { colorProbe = document.createElement('span'); colorProbe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden'; document.body.appendChild(colorProbe); }
|
||||
let best = null, bd = Infinity;
|
||||
STYLE_GROUPS.color.forEach((c) => {
|
||||
colorProbe.className = 'cs-color-' + c;
|
||||
const p = rgb(getComputedStyle(colorProbe).color); if (!p) return;
|
||||
const d = (p[0]-a[0])**2 + (p[1]-a[1])**2 + (p[2]-a[2])**2;
|
||||
if (d < bd) { bd = d; best = c; }
|
||||
});
|
||||
colorProbe.className = '';
|
||||
return best;
|
||||
}
|
||||
function effective(group, el) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (group === 'size') return nearestSize(el);
|
||||
if (group === 'color') return nearestColor(el);
|
||||
if (group === 'weight') return parseInt(cs.fontWeight, 10) >= 600 ? 'bold' : 'normal';
|
||||
if (group === 'style') return cs.fontStyle.indexOf('italic') >= 0 ? 'italic' : 'normal';
|
||||
if (group === 'align') { let x = cs.textAlign; if (x === 'start') x = 'left'; else if (x === 'end') x = 'right'; return ['left','center','right','justify'].indexOf(x) >= 0 ? x : 'left'; }
|
||||
return null;
|
||||
}
|
||||
const modal = document.getElementById('tag-modal');
|
||||
const tmTag = document.getElementById('tm-tag');
|
||||
const tmGuid = document.getElementById('tm-guid');
|
||||
const tmBody = document.getElementById('tm-body');
|
||||
const tmMsg = document.getElementById('tm-msg');
|
||||
const tmSave = document.getElementById('tm-save');
|
||||
let current = null; // { tag, type, el }
|
||||
|
||||
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
|
||||
document.getElementById('tm-cancel').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||
|
||||
async function openModal(tag, el) {
|
||||
tmMsg.textContent = '';
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
|
||||
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
|
||||
data = await res.json();
|
||||
} catch { alert('Errore di rete.'); return; }
|
||||
current = { tag: data.tag, type: data.type, el };
|
||||
tmTag.textContent = data.tag;
|
||||
tmGuid.textContent = data.guid || '';
|
||||
tmBody.innerHTML = '';
|
||||
if (data.type === 'image') {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
|
||||
tmBody.appendChild(inp);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del'; del.className = 'tm-del';
|
||||
tmBody.appendChild(del);
|
||||
del.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
|
||||
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.id = 'tm-value'; ta.value = data.value;
|
||||
tmBody.appendChild(ta);
|
||||
const styles = data.styles || {};
|
||||
const groups = (data.styleOptions || []).filter((g) => STYLE_GROUPS[g]);
|
||||
if (groups.length) {
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'tm-styles';
|
||||
let anyEstimate = false;
|
||||
groups.forEach((group) => {
|
||||
const explicit = styles[group]; // valore salvato nel blocco
|
||||
const est = explicit ? null : effective(group, el); // altrimenti stima dall'aspetto reale
|
||||
const shown = explicit || est;
|
||||
if (est) anyEstimate = true;
|
||||
const field = document.createElement('label');
|
||||
field.className = 'tm-field';
|
||||
const cap = document.createElement('span');
|
||||
cap.className = 'tm-field__label';
|
||||
cap.textContent = GROUP_LABELS[group] || group;
|
||||
const sel = document.createElement('select');
|
||||
sel.dataset.group = group;
|
||||
sel.className = 'tm-style';
|
||||
if (est) sel.dataset.estimated = est; // marcata come stima: non fissata al salvataggio se invariata
|
||||
const none = document.createElement('option');
|
||||
none.value = ''; none.textContent = '— default (tema) —';
|
||||
sel.appendChild(none);
|
||||
STYLE_GROUPS[group].forEach((v) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = v; o.textContent = valLabel(group, v) + (est === v ? ' (attuale)' : '');
|
||||
if (shown === v) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.classList.add(explicit ? 'is-set' : (est ? 'is-estimated' : ''));
|
||||
sel.addEventListener('change', () => {
|
||||
delete sel.dataset.estimated; // scelta manuale → diventa esplicita
|
||||
sel.classList.remove('is-estimated');
|
||||
sel.classList.toggle('is-set', !!sel.value);
|
||||
});
|
||||
field.appendChild(cap); field.appendChild(sel);
|
||||
grid.appendChild(field);
|
||||
});
|
||||
tmBody.appendChild(grid);
|
||||
if (anyEstimate) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'tm-note';
|
||||
note.textContent = 'I valori "(attuale)" riflettono l’aspetto dal tema: cambiane uno per fissarlo sul blocco.';
|
||||
tmBody.appendChild(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
modal.hidden = false;
|
||||
}
|
||||
|
||||
tmSave.addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
tmMsg.textContent = '';
|
||||
if (current.type === 'image') {
|
||||
const file = document.getElementById('tm-file').files[0];
|
||||
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
|
||||
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
|
||||
return;
|
||||
}
|
||||
const value = document.getElementById('tm-value').value;
|
||||
const selects = document.querySelectorAll('.tm-style');
|
||||
const styles = {};
|
||||
selects.forEach((s) => {
|
||||
const v = s.value;
|
||||
if (!v) return; // "default (tema)" → non impostato
|
||||
if (s.dataset.estimated && v === s.dataset.estimated) return; // stima non modificata → non fissare
|
||||
styles[s.dataset.group] = v;
|
||||
});
|
||||
const body = { value };
|
||||
if (selects.length) body.styles = styles; // inviato sempre (anche {}) così l'azzeramento persiste
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
|
||||
const saved = await r.json();
|
||||
// Aggiorna in-place il contenuto nel DOM.
|
||||
if (current.type === 'html') current.el.innerHTML = saved.value;
|
||||
else current.el.textContent = saved.value;
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-tag]').forEach((el) => {
|
||||
const tag = el.dataset.tag;
|
||||
const guid = el.dataset.guid || '';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tag-badge';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
|
||||
const edit = document.createElement('button');
|
||||
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
|
||||
edit.addEventListener('click', () => openModal(tag, el));
|
||||
badge.appendChild(label); badge.appendChild(edit);
|
||||
document.body.appendChild(badge);
|
||||
const place = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
badge.style.top = window.scrollY + r.top - 16 + 'px';
|
||||
badge.style.left = window.scrollX + r.left + 'px';
|
||||
};
|
||||
place();
|
||||
addEventListener('scroll', place, { passive: true });
|
||||
addEventListener('resize', place);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { randomBytes } from 'node:crypto';
|
||||
export const SESSION_COOKIE = 'session';
|
||||
const SESSION_DAYS = 7;
|
||||
|
||||
export type Role = 'admin' | 'superuser' | 'user';
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
return bcrypt.hashSync(plain, 12);
|
||||
}
|
||||
@@ -13,9 +15,9 @@ export function verifyPassword(plain: string, hash: string): boolean {
|
||||
return bcrypt.compareSync(plain, hash);
|
||||
}
|
||||
|
||||
export function createUser(db: Database.Database, username: string, password: string): number {
|
||||
const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
.run(username, hashPassword(password));
|
||||
export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
|
||||
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run(username, hashPassword(password), role);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -30,19 +32,83 @@ export function login(db: Database.Database, username: string, password: string)
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null {
|
||||
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null {
|
||||
const row = db.prepare(
|
||||
`SELECT s.token, s.expires_at, u.id, u.username
|
||||
`SELECT s.token, s.expires_at, u.id, u.username, u.role
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
|
||||
).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined;
|
||||
).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined;
|
||||
if (!row) return null;
|
||||
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
return null;
|
||||
}
|
||||
return { id: row.id, username: row.username };
|
||||
return { id: row.id, username: row.username, role: row.role };
|
||||
}
|
||||
|
||||
export function logout(db: Database.Database, token: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
|
||||
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
|
||||
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
|
||||
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
|
||||
// utenti già autenticati).
|
||||
const RULES: [RegExp, Role[]][] = [
|
||||
[/^\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
];
|
||||
|
||||
export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||||
if (role === 'admin') return true;
|
||||
for (const [re, roles] of RULES) {
|
||||
if (re.test(pathname)) return roles.includes(role as Role);
|
||||
}
|
||||
return true; // blog, upload, logout, showtags: tutti i loggati
|
||||
}
|
||||
|
||||
export function getUsernameById(db: Database.Database, id: number): string | null {
|
||||
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
|
||||
return row?.username ?? null;
|
||||
}
|
||||
|
||||
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
|
||||
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
|
||||
{ id: number; username: string; role: string }[];
|
||||
}
|
||||
|
||||
export function countAdmins(db: Database.Database): number {
|
||||
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
|
||||
}
|
||||
|
||||
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
|
||||
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
|
||||
{ id: number; username: string; role: string } | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
}
|
||||
|
||||
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
|
||||
}
|
||||
|
||||
export function deleteUser(db: Database.Database, id: number): void {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function randomPassword(): string {
|
||||
return randomBytes(9).toString('base64url'); // ~12 caratteri
|
||||
}
|
||||
|
||||
export function isRole(v: unknown): v is Role {
|
||||
return v === 'admin' || v === 'superuser' || v === 'user';
|
||||
}
|
||||
|
||||
export function landingFor(role: string): string {
|
||||
if (role === 'superuser') return '/admin/content';
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { validateStyles } from './style-presets';
|
||||
import { sanitizeInline } from './sanitize';
|
||||
|
||||
interface Body { value?: unknown; styles?: unknown }
|
||||
|
||||
export function applyContentUpdate(db: Database.Database, tag: string, body: Body, username: string):
|
||||
{ ok: true } | { ok: false; status: number; error: string } {
|
||||
const row = db.prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined;
|
||||
if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' };
|
||||
|
||||
const sets: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (body.value !== undefined) {
|
||||
if (typeof body.value !== 'string') return { ok: false, status: 400, error: 'Valore non valido.' };
|
||||
if (row.type === 'image') return { ok: false, status: 400, error: 'Le immagini si aggiornano via upload.' };
|
||||
sets.push('value = ?');
|
||||
args.push(row.type === 'html' ? sanitizeInline(body.value) : body.value);
|
||||
}
|
||||
if (body.styles !== undefined) {
|
||||
const v = validateStyles(body.styles);
|
||||
if (!v.ok) return { ok: false, status: 400, error: v.error };
|
||||
sets.push('styles = ?');
|
||||
args.push(JSON.stringify(v.styles));
|
||||
}
|
||||
if (!sets.length) return { ok: false, status: 400, error: 'Nessuna modifica.' };
|
||||
sets.push(`updated_at = datetime('now')`, 'updated_by = ?');
|
||||
args.push(username, tag);
|
||||
db.prepare(`UPDATE content_blocks SET ${sets.join(', ')} WHERE tag = ?`).run(...args);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from './db';
|
||||
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
|
||||
import { stylesToClasses } from './style-presets';
|
||||
|
||||
interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
|
||||
|
||||
export function syncSeed(db: Database.Database): void {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
|
||||
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
|
||||
tx();
|
||||
}
|
||||
|
||||
export function makeContentStore(dbGetter: () => Database.Database) {
|
||||
let cache: Map<string, Entry> | null = null;
|
||||
|
||||
const load = (): Map<string, Entry> => {
|
||||
if (cache) return cache;
|
||||
const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as
|
||||
{ tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
|
||||
cache = new Map(rows.map((r) => {
|
||||
let styles: Record<string, string> = {};
|
||||
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
|
||||
if (typeof styles !== 'object' || styles === null) styles = {};
|
||||
return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
|
||||
}));
|
||||
return cache;
|
||||
};
|
||||
|
||||
return {
|
||||
resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
|
||||
const hit = load().get(tag);
|
||||
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
|
||||
const seed = seedByTag.get(tag);
|
||||
if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
|
||||
console.warn(`[content] tag sconosciuto: ${tag}`);
|
||||
return { value: '', type: 'text', classes: '', guid: '' };
|
||||
},
|
||||
invalidate(): void { cache = null; },
|
||||
};
|
||||
}
|
||||
|
||||
const store = makeContentStore(getDb);
|
||||
|
||||
export const resolveContent = (tag: string) => store.resolve(tag);
|
||||
export const t = (tag: string): string => store.resolve(tag).value;
|
||||
export const invalidateContentCache = (): void => store.invalidate();
|
||||
|
||||
export function getImageOverride(tag: string): string | null {
|
||||
const r = store.resolve(tag);
|
||||
return r.type === 'image' && r.value ? `/uploads/${r.value}` : null;
|
||||
}
|
||||
|
||||
export const contentImageUrl = (tag: string, fallback: string): string =>
|
||||
getImageOverride(tag) ?? fallback;
|
||||
@@ -1,6 +1,8 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { contentSeed } from '../data/content-seed';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
@@ -27,6 +29,14 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS content_blocks (
|
||||
tag TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('text','html','image')),
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
styles TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_by TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
export function createDb(path?: string): Database.Database {
|
||||
@@ -36,6 +46,154 @@ export function createDb(path?: string): Database.Database {
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
// Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role.
|
||||
const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
|
||||
if (!userCols.some((c) => c.name === 'role')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
|
||||
}
|
||||
// Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
|
||||
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
|
||||
|
||||
// GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
|
||||
const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
|
||||
if (!contentCols.some((c) => c.name === 'guid')) {
|
||||
db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
|
||||
}
|
||||
|
||||
// Autore articoli: colonna nullable (post storici restano senza autore).
|
||||
const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
|
||||
if (!postCols.some((c) => c.name === 'author_id')) {
|
||||
db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
|
||||
}
|
||||
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
|
||||
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
|
||||
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
|
||||
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
|
||||
const TAG_RENAMES: [string, string][] = [
|
||||
['home.programs.', 'home.services.'],
|
||||
['footer.programs.', 'footer.services.'],
|
||||
['programs.', 'services.'],
|
||||
['program.', 'service.'],
|
||||
];
|
||||
const renameUpd = db.prepare('UPDATE OR IGNORE content_blocks SET tag = ? || substr(tag, ?) WHERE tag LIKE ?');
|
||||
const renameDel = db.prepare('DELETE FROM content_blocks WHERE tag LIKE ?');
|
||||
const renameTx = db.transaction(() => {
|
||||
for (const [oldP, newP] of TAG_RENAMES) {
|
||||
renameUpd.run(newP, oldP.length + 1, `${oldP}%`);
|
||||
renameDel.run(`${oldP}%`);
|
||||
}
|
||||
// Tag singolo, non coperto dai prefissi: uguaglianza esatta.
|
||||
db.prepare("UPDATE OR IGNORE content_blocks SET tag = 'footer.col.services-title' WHERE tag = 'footer.col.programs-title'").run();
|
||||
db.prepare("DELETE FROM content_blocks WHERE tag = 'footer.col.programs-title'").run();
|
||||
|
||||
// I valori di default che nominavano la sezione cambiano con la rinomina. Il seed è
|
||||
// INSERT OR IGNORE e non tocca le righe esistenti, quindi si aggiornano qui — ma solo
|
||||
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
|
||||
// il nuovo valore è quello del seed corrente per lo stesso tag.
|
||||
const OLD_SECTION_DEFAULTS: [string, string][] = [
|
||||
['global.nav.3.label', 'Programmi'],
|
||||
['footer.col.services-title', 'Programmi'],
|
||||
['home.services.title', 'Programmi'],
|
||||
['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'],
|
||||
['services.meta.title', 'Programmi'],
|
||||
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
|
||||
['services.hero.title', 'Programmi'],
|
||||
["services.intro.body", "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."],
|
||||
['service.side.others-label', 'Altri programmi'],
|
||||
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
|
||||
];
|
||||
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
|
||||
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
|
||||
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
|
||||
const next = seedValue.get(tag);
|
||||
if (next !== undefined) updValue.run(next, tag, oldValue);
|
||||
}
|
||||
});
|
||||
renameTx();
|
||||
|
||||
// Modifiche 03: "Personal Training" → "One to One" (nuovo slug one-to-one). Rinomina i tag
|
||||
// esistenti prima del seed per preservare le modifiche da pannello; il title di default viene
|
||||
// aggiornato solo se non modificato. La nuova pagina "Small Group" (slug small-group) è un
|
||||
// servizio nuovo e viene inserita dal seed più sotto. Idempotente.
|
||||
const oneToOneTx = db.transaction(() => {
|
||||
renameUpd.run('service.one-to-one.', 'service.personal-training.'.length + 1, 'service.personal-training.%');
|
||||
renameDel.run('service.personal-training.%');
|
||||
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal Training'").run();
|
||||
});
|
||||
oneToOneTx();
|
||||
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
|
||||
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
|
||||
syncTx();
|
||||
|
||||
// Modifiche 02 (pagina Servizi): "Pilates Flow" → "Profilazione" e "Rehab" → "Osteopatia".
|
||||
// I nuovi servizi hanno slug nuovi (tag service.profilazione.* / service.osteopatia.*) e
|
||||
// vengono inseriti dal seed qui sopra. Restano da aggiornare le righe EXISTING il cui valore
|
||||
// cambia: label del footer e meta description. UPDATE guardato dal vecchio valore → idempotente
|
||||
// e non sovrascrive eventuali modifiche fatte dal pannello.
|
||||
const SERVIZI_02: [string, string, string][] = [
|
||||
['footer.services.4.label', 'Rehab', 'Profilazione'],
|
||||
['footer.services.5.label', 'Pilates Flow', 'Osteopatia'],
|
||||
['services.meta.description',
|
||||
'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.',
|
||||
'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.'],
|
||||
// Pagina Training: riquadri passati a One to One, Small Group, Rehab, Holistic Class,
|
||||
// Reformer Class, Coaching. Label footer + meta aggiornate (righe 5-6 sono nuovi tag).
|
||||
['footer.training.2.label', 'Small groups', 'Small group'],
|
||||
['footer.training.3.label', 'Performance class', 'Rehab'],
|
||||
['footer.training.4.label', 'Fit remote', 'Holistic class'],
|
||||
['training.meta.description',
|
||||
'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.',
|
||||
'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.'],
|
||||
];
|
||||
const upd02 = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
|
||||
const upd02Tx = db.transaction(() => { for (const [tag, oldV, newV] of SERVIZI_02) upd02.run(newV, tag, oldV); });
|
||||
upd02Tx();
|
||||
|
||||
// Metodo (home): step riscritti dal pannello in dev ma mai propagati in produzione
|
||||
// (il DB non è versionato: gli edit vivono solo nel volume). Qui si allineano le righe
|
||||
// di prod ancora ai vecchi default del seed, senza toccare eventuali modifiche successive.
|
||||
const METODO: [string, string, string][] = [
|
||||
['method.step.1.text', 'Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', 'La base di partenza per definire le priorità del percorso di ciascun cliente.'],
|
||||
['method.step.2.title', 'Prima seduta operativa', 'Check-up fisico'],
|
||||
['method.step.2.text', 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', 'Profilazione fisica per definire lo stato di salute iniziale del cliente.'],
|
||||
['method.step.3.title', 'Plicometria', 'Insanity plan'],
|
||||
['method.step.3.text', 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', 'Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.'],
|
||||
['method.step.4.title', 'Analisi antropometriche', 'Monitoraggio costante'],
|
||||
['method.step.4.text', 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', 'Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.'],
|
||||
];
|
||||
const updMetodoTx = db.transaction(() => { for (const [tag, oldV, newV] of METODO) upd02.run(newV, tag, oldV); });
|
||||
updMetodoTx();
|
||||
|
||||
// Modifiche 03: due ingressi dello stesso stabile → indirizzo esteso in footer e contatti.
|
||||
// Il tag global.contact.map-query è nuovo (inserito dal seed); qui si aggiorna solo l'indirizzo
|
||||
// già esistente, se non modificato dal pannello. Idempotente.
|
||||
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
|
||||
|
||||
// Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE
|
||||
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
|
||||
// rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta).
|
||||
const ABOUT_02: [string, string, string][] = [
|
||||
['about.about.body', '%unire fitness e salute in un unico percorso%',
|
||||
"InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."],
|
||||
['about.convenzioni.item1', '%small group training e classi di gruppo%',
|
||||
'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'],
|
||||
['about.convenzioni.item2', '%consulenza gratuita preliminare%',
|
||||
'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'],
|
||||
['about.studios.body1', '%della performance. Più che un semplice insieme%',
|
||||
"IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."],
|
||||
];
|
||||
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
|
||||
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
|
||||
updAboutTx();
|
||||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
||||
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
||||
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||||
const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
|
||||
const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
|
||||
bfTx();
|
||||
// Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@ import type Database from 'better-sqlite3';
|
||||
|
||||
export interface Post {
|
||||
id: number; title: string; slug: string; category: string; excerpt: string;
|
||||
body_html: string; cover: string | null; draft: number;
|
||||
body_html: string; cover: string | null; draft: number; author_id: number | null;
|
||||
published_at: string | null; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
export interface PostInput {
|
||||
title: string; slug: string; category: string; excerpt: string;
|
||||
body_html: string; cover?: string | null; draft: boolean;
|
||||
body_html: string; cover?: string | null; draft: boolean; author_id?: number | null;
|
||||
}
|
||||
|
||||
export function createPost(db: Database.Database, input: PostInput): number {
|
||||
const res = db.prepare(
|
||||
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
|
||||
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
|
||||
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
|
||||
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id,
|
||||
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
|
||||
).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||||
).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -72,3 +72,12 @@ export function listArchiveMonths(db: Database.Database): { month: string; count
|
||||
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
|
||||
).all() as { month: string; count: number }[];
|
||||
}
|
||||
|
||||
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
|
||||
return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
|
||||
}
|
||||
|
||||
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
|
||||
if (role === 'admin' || role === 'superuser') return true;
|
||||
return post.author_id === userId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
|
||||
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
|
||||
export function safeNext(next: string | null): string {
|
||||
if (next && next.includes('\\')) return '/';
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
|
||||
if (next.startsWith('/api/')) return '/';
|
||||
return next;
|
||||
}
|
||||
@@ -9,3 +9,12 @@ export function sanitizeHtml(html: string): string {
|
||||
allowProtocolRelative: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeInline(html: string): string {
|
||||
return sanitize(html, {
|
||||
allowedTags: ['strong', 'em', 'u', 's', 'br', 'a'],
|
||||
allowedAttributes: { a: ['href', 'rel', 'target'] },
|
||||
allowedSchemes: ['https', 'http', 'mailto'],
|
||||
allowProtocolRelative: false,
|
||||
});
|
||||
}
|
||||
|
||||