Compare commits
23 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 |
@@ -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,145 +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**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati)
|
||||
- **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: `-- <nome> <password> [admin\|editor]` |
|
||||
| `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]` |
|
||||
|
||||
## Gestione Contenuti
|
||||
## Gestione contenuti
|
||||
|
||||
I testi e le immagini del sito vetrina sono contenuti taggati, modificabili senza toccare il codice tramite il pannello `/admin/content`. Ogni blocco di contenuto è identificato da un tag (ad esempio `home.hero.title`) e può essere aggiornato nel valore, negli stili predefiniti (dimensione, peso, colore) e — per le immagini — sostituito con un upload o riportato all'originale.
|
||||
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.
|
||||
|
||||
Il pannello raggruppa i tag per pagina, offre una ricerca per tag o contenuto e mostra per ogni blocco l'ultima modifica effettuata. Visitando il sito da utente autenticato con il parametro `?annotate=1` (link "Vedi sito con tag" nel pannello), ogni contenuto taggato viene evidenziato con un badge che rimanda direttamente al form di modifica corrispondente.
|
||||
- **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.
|
||||
|
||||
Sono previsti due ruoli utente: `admin` ha accesso completo (articoli del blog, contenuti, upload), mentre `editor` può operare esclusivamente sul pannello contenuti. Gli utenti si creano con `npm run create-user -- <nome> <password> editor`.
|
||||
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.
|
||||
|
||||
## Struttura Progetto
|
||||
> 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.
|
||||
|
||||
@@ -2,12 +2,18 @@ import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
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' }),
|
||||
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
|
||||
|
||||
@@ -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,101 +0,0 @@
|
||||
# Inventario contenuti — pagine vetrina (2026-07-05)
|
||||
|
||||
Documento di riferimento per il piano `2026-07-05-contenuti-taggati-pannello-editor.md`. Elenca, file per file, i testi visibili e le immagini da estrarre nel sistema di tag. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione; qui compaiono abbreviati per identificazione.
|
||||
|
||||
**Nota nomenclatura**: i nomi tag canonici sono quelli nella sezione "Interfaces → Produces" dei task del piano (segmenti kebab-case, es. `home.hero.title-line-1`); dove questo inventario usa camelCase va convertito.
|
||||
|
||||
## Pagine
|
||||
|
||||
### `src/pages/index.astro` (home)
|
||||
Solo orchestrazione componenti. Hardcoded: `home.meta.title` ("Performance. Balance. Longevity.", prop title di Base), `home.training.title` ("Training"), `home.training.subtitle` ("Porta i tuoi allenamenti al livello successivo: trova il percorso…"). Nessuna immagine diretta.
|
||||
|
||||
### `src/pages/about.astro`
|
||||
Meta (2): `about.meta.title` ("Insanitylab"), `about.meta.description` ("Mission, storia, team e metodo…"). Hero: `about.hero.title` ("Insanitylab", via PageHero).
|
||||
Sezioni testo: mission (title + body), direzione (title + body + cta "Scopri"), approccio (title + body + cta "Contattaci"), about (title + body), convenzioni (title + body + item1-3 in `<ul class="conv">` + riga finale con `{site.email}` · `{site.phone}` interpolati → spezzare in `about.convenzioni.cta-text` + tag global), team (title + body + staff-intro), metodo (title + body + closing), studios (title + body1-3), vision (title + body1-2).
|
||||
Immagini (glob, alt hardcoded): `about.mission.image` (about-training-group.jpg), `about.about.image` (about-pushup.jpg), `about.convenzioni.image` (about-conventions.jpg), `about.metodo.image` (about-team-group.jpg), `about.studios.image` (about-studios.jpg), `about.vision.image` (about-vision.jpg). Griglia team: 9 foto da `imageAbout` (tag `team.<slug>.image-about`).
|
||||
|
||||
### `src/pages/contact.astro`
|
||||
`contact.meta.title` ("Contatti"), `contact.meta.description`, `contact.hero.title` ("Contattaci"), `contact.hero.image` (contact-hero.jpg, background PageHero), `contact.map.button` ("Carica la mappa (Google Maps)"), `contact.map.note` ("La mappa viene caricata da Google solo dopo il tuo consenso."), label info (address-label "Indirizzo", phone-label "Telefono", email-label "E-mail" — i valori sono i tag `global.contact.*`), `contact.invite` ("Per informazioni o per richiedere una consulenza iniziale…"). Iframe Maps generato via JS (title tecnico, escluso).
|
||||
|
||||
### `src/pages/training.astro`
|
||||
`training.meta.title`, `training.meta.description`, `training.hero.title` ("training"), `training.intro.title` ("Training"), `training.intro.subtitle` ("Porta i tuoi allenamenti al livello successivo: programmi completi…" — variante diversa dalla home), `training.trial.title` ("Prenota la lezione di prova"), `training.trial.body`, `training.trial.cta` ("Prenota"), `training.trial.image` (trial-class.jpg). Blocchi per-training: testi dai tag `training.<id>.*` (vedi trainings.ts); nota `<h2>{t.title.toLowerCase()}</h2>` (lowercase a runtime, conservare).
|
||||
|
||||
### `src/pages/404.astro`
|
||||
`notfound.meta.title`, `notfound.eyebrow` ("Errore 404"), `notfound.title` ("Pagina non trovata"), `notfound.body`, `notfound.cta` ("Torna alla home").
|
||||
|
||||
### `src/pages/programs/index.astro`
|
||||
`programs.meta.title`, `programs.meta.description`, `programs.hero.title` ("Programmi"), `programs.intro.title`, `programs.intro.body` ("Questi sono i programmi delle classi di Insanity Lab…"), `programs.card.more-label` ("Per saperne di più →", ripetuto). Card e immagini (`program-<slug>.jpg`, alt = title) da programs.ts.
|
||||
|
||||
### `src/pages/programs/[slug].astro`
|
||||
Hardcoded: `program.side.categories-label` ("Categorie"), `program.side.category.1-4` ("Fitness", "Salute", "Stile di vita", "Nutrizione"), `program.side.others-label` ("Altri programmi"), `program.pricing.empty-note` (html con link "Contattaci per i dettagli… Scrivici →"), `program.pricing.per` ("al mese, semestrale"), `program.pricing.entries-singular/-plural` ("ingresso settimanale"/"ingressi settimanali"), `program.pricing.buy-cta` ("Acquista").
|
||||
Dinamici: meta = title/excerpt del programma; hero = `title.toUpperCase()` (conservare la trasformazione); corpo/features/faq/pricing da data. Immagine `program-<slug>.jpg` come hero bg e inline.
|
||||
|
||||
## Componenti condivisi
|
||||
|
||||
### `Header.astro`
|
||||
Logo `/img/logo-dark.png` (public) con alt "InsanityLab" (`header.logo.alt`). Nav: label da `site.navigation` → tag `global.nav.<i>.label` (5 voci: "Insanitylab", "Training", "Programmi", "Blog & Edugo", "Contatti"). Aria-label esclusi.
|
||||
|
||||
### `Footer.astro`
|
||||
Logo light + alt; `footer.tagline` (html, "PERFORMANCE. BALANCE. LONGEVITY."); `footer.about` ("Promuoviamo il movimento come cura di sé…"); riga contatti con `<br>` (comporre dai tag `global.contact.*`); orari (`global.hours.1/2`); titoli colonne training/programs/contact; `footer.copyright` ("Insanitylab, All Rights Reserved" — `<strong>` e anno dinamico restano fuori dal tag); `footer.social-label` ("Seguici"); label link da `site.footerTraining` (4) e `site.footerPrograms` (6) → `footer.training.<i>.label`, `footer.programs.<i>.label`.
|
||||
|
||||
### `ContactForm.astro`
|
||||
Placeholder: first-name "Nome", last-name "Cognome" (solo full), phone "Telefono" (solo full), email "E-mail", message "Messaggio". Submit "Invia messaggio". Messaggi nello `<script>` client (passare via data-*): success "Messaggio inviato, ti ricontatteremo al più presto.", error-generic "Errore di invio, riprova più tardi.", error-network "Errore di rete, riprova più tardi.".
|
||||
|
||||
### `Agenda.astro` + `agenda.ts`
|
||||
`agenda.title` ("Agenda"); da agenda.ts: `agenda.discipline.<i>` (7: Events, Crossfit, Cardio, Box, Meditation, Yoga Classes, Body Balance), `agenda.day.<i>` (7: Lunedì…Domenica), `agenda.slot.<i>` (5: 10.00…20.00). **Celle (28 voci discipline/time/trainer) ESCLUSE**: dati placeholder Prowess, in attesa orario reale dal cliente.
|
||||
|
||||
### `PageHero.astro`
|
||||
Nessun testo proprio (props title/eyebrow/bgImage passate dalle pagine con i rispettivi tag). Per bgImage da override serve accettare anche URL stringa.
|
||||
|
||||
## Componenti home
|
||||
|
||||
### `HeroSlider.astro`
|
||||
`home.hero.title-line-1/-2/-3` ("PERFORMANCE." / "BALANCE." / "LONGEVITY."), `home.hero.cta` ("Scopri"), `home.hero.image` (hero-1.png, alt "Atleti InsanityLab"). Frecce slider: aria, escluse.
|
||||
|
||||
### `InfoColumns.astro`
|
||||
3 colonne: `home.info.col<n>.title` ("PERFORMANCE." / "BALANCE." / "LONGEVITY.") + `home.info.col<n>.image` (info-performance/balance/longevity.jpg, background via getImage → usare `contentImageUrl`).
|
||||
|
||||
### `FeatureBlocks.astro`
|
||||
3 blocchi × (title, body, cta "Scopri") → `home.feature.<performance|balance|longevity>.title/.body/.cta` + `.image` (feature-*.jpg, alt = title).
|
||||
|
||||
### `QuoteBanner.astro`
|
||||
`home.quote.text` (html — congelare nel seed l'output della regex che avvolge le parole chiave in `<strong>`, poi rimuovere la regex), `home.quote.cite` ("Insanitylab"), `home.quote.image` (quote-bg.jpg, background). Testo renderizzato 2× per il marquee (seconda copia aria-hidden).
|
||||
|
||||
### `TrainingCards.astro`
|
||||
`home.training-cards.link-label` ("Scopri →"). Card: testi/immagini dai tag `training.<id>.*`. Usato in home e in training.astro.
|
||||
|
||||
### `ProgramsGrid.astro`
|
||||
`home.programs.title` ("Programmi"), `home.programs.body` (uguale a programs.intro.body ma tag separato). Griglia da programs.ts + SVG da program-icons.ts (icone escluse dal tagging).
|
||||
|
||||
### `TeamSection.astro`
|
||||
`home.team.title` (html, "Team<br>Trainer"), `home.team.cta` ("Scopri team"). Membri: filtro `homeTeam` su team.ts, foto `team.<slug>.image`, figcaption = shortName.
|
||||
|
||||
### `MethodSteps.astro`
|
||||
`home.method.title` (html, "Il metodo<br>InsanityLab"). Intro/note/steps dai tag `method.*` (intro html con `<strong>` congelato, rimuovere il set:html con wrapping runtime). Immagini step alt="" (decorative).
|
||||
|
||||
### `PartnersStrip.astro`
|
||||
Solo loghi: `partner.<i>.name` (alt) + `partner.<i>.image` da partners.ts.
|
||||
|
||||
## Componente about
|
||||
|
||||
### `about/Timeline.astro`
|
||||
`about.storylab.title` ("Storylab"), `about.storylab.body`, `about.timeline.label` ("INSANITYLAB 2021-2024"), `about.timeline.open-text`, `about.timeline.stage<1-3>.label` ("INSANITYLAB 2025"/"2026"/"OGGI") + `.text` (placeholder "Contenuto in arrivo…" — testi Storylab attesi dal cliente). Struttura `<details>/<summary>` da conservare.
|
||||
|
||||
## Data file
|
||||
|
||||
- **site.ts**: phone "351 7535977", email "info@insanitylab.it", address "Via Leandro Alberti, 76", cityLine "40139 Bologna", hours (2), socials (3, label+url — url restano nel data file), navigation (5), footerTraining (4), footerPrograms (6).
|
||||
- **team.ts**: 9 membri (slug da shortName lowercase): donata, nicola, eva (founder); alessandro, eleonora, antonello, alice, davide, giulia. Campi: name, role, bio → `team.<slug>.name/.role/.bio`; immagini: `image` (4: donata, nicola, eleonora, antonello) e `imageAbout` (tutti e 9, file `team-*-dark`).
|
||||
- **programs.ts**: 6 slug: personal-training, performance-class, coaching, pilates-flow, rehab, nutrition. Campi: title, subtitle, excerpt, longDescription, features[] (4-5), faq[] (3-6 coppie q/a). Solo performance-class ha hasPricing. → `program.<slug>.title/.subtitle/.excerpt/.long-description/.feature.<i>/.faq.<i>.q/.a`.
|
||||
- **pricing.ts**: heading "Acquista la tua performance class", intro, 3 gruppi (label; solo il gruppo 1 "semestrale" ha 3 plans). Per plan: monthly/entries (numerici, restano nel data file), note, installments, best → `pricing.group.1.plan.<i>.note/.installments/.best`.
|
||||
- **method.ts**: intro (html), extraNote, 4 step (title, text, image method-step-1..4) → `method.intro`, `method.extra-note`, `method.step.<n>.title/.text/.image`.
|
||||
- **trainings.ts**: 4 id: one-to-one, small-groups, performance-class, fit-remote. Campi title, caption, description, cta.label (href resta) + image → `training.<id>.title/.caption/.description/.cta-label/.image`.
|
||||
- **partners.ts**: 4: "Ticket Welfare Edenred", "Welbee", "AON", "Howden" → `partner.<i>.name/.image`.
|
||||
- **program-icons.ts**: path SVG, esclusi dal tagging.
|
||||
|
||||
## Note trasversali
|
||||
|
||||
1. Tag `type: 'html'` necessari per: footer.tagline (` `), home.team.title e home.method.title (`<br>`), home.quote.text e method.intro (`<strong>` congelato), program.pricing.empty-note (link `<a>`).
|
||||
2. Trasformazioni runtime da conservare nel template (non nel valore): `.toLowerCase()` (training h2), `.toUpperCase()` (hero dettaglio programma), anno copyright.
|
||||
3. Duplicazioni intenzionali (tag distinti, valori oggi identici): programs.intro.body / home.programs.body / pricing.intro; home.training.subtitle / training.intro.subtitle (varianti).
|
||||
4. Loghi header/footer stanno in `public/img/` (non passano dalla pipeline asset): TImg non applicabile — per l'override basta `contentImageUrl(tag, '/img/logo-dark.png')` su `<img>`.
|
||||
5. Conteggi indicativi: ~150 tag testo da template, ~200 da data file, ~60 slot immagine.
|
||||
@@ -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).
|
||||
@@ -1,146 +0,0 @@
|
||||
# Sistema di contenuti taggati e pannello editor
|
||||
|
||||
Data: 2026-07-05
|
||||
Stato: approvato dall'operatore (design), spec in revisione
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Ogni testo e ogni immagine delle pagine vetrina del sito riceve un tag univoco e stabile. I contenuti vengono estratti dal codice e salvati in SQLite, così da poter essere modificati senza intervenire sui sorgenti. Un pannello di amministrazione (`/admin/content`) permette a un utente con ruolo dedicato di modificare testi, stili (tramite preset controllati) e immagini; una modalità "annotate" sovrappone al sito i badge con i nomi dei tag, rendendo immediato individuare l'elemento da modificare.
|
||||
|
||||
Il sistema serve due scopi complementari: nel breve periodo dà all'operatore un vocabolario preciso per dettare modifiche ("cambia `home.hero.title` in…"); nel medio periodo consente al cliente di aggiornare i contenuti in autonomia, senza rischio di rompere il layout.
|
||||
|
||||
## Decisioni prese in fase di design
|
||||
|
||||
| Decisione | Scelta |
|
||||
| --- | --- |
|
||||
| Scopo | Tagging + pannello editor nello stesso progetto |
|
||||
| Ambito modifiche | Testi, immagini, stili tramite preset chiusi (no CSS libero) |
|
||||
| Utenti | Ruolo `admin` (tutto) e ruolo `editor` (solo contenuti) |
|
||||
| Pubblicazione | Pagine vetrina passano a SSR con cache in memoria invalidata al salvataggio |
|
||||
| UX | Ibrido: pannello `/admin/content` + overlay annotate sul sito con link ai form |
|
||||
|
||||
## Architettura
|
||||
|
||||
### 1. Schema dei tag
|
||||
|
||||
Formato gerarchico `pagina.sezione.elemento`, tutto minuscolo, separatore punto, senza spazi:
|
||||
|
||||
- `home.hero.title`, `home.hero.subtitle`, `home.hero.cta`
|
||||
- `about.team.donata.bio`, `about.intro.body`
|
||||
- `programs.pilates-flow.price`, `training.fit-remote.description`
|
||||
- `global.footer.tagline`, `global.header.cta` (elementi condivisi tra pagine)
|
||||
|
||||
I tag sono stabili: una volta assegnati non cambiano nome, perché sono chiave primaria nel DB e riferimento nelle conversazioni. I dati oggi strutturati in `src/data/*.ts` (team, pricing, agenda, method, programs, trainings, partners, site) vengono scomposti in tag granulari con lo stesso schema.
|
||||
|
||||
### 2. Storage (SQLite, DB esistente)
|
||||
|
||||
Nuova tabella nella `SCHEMA` di `src/lib/db.ts`:
|
||||
|
||||
```sql
|
||||
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 '{}', -- JSON, solo chiavi preset
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_by TEXT
|
||||
);
|
||||
```
|
||||
|
||||
- `type = 'text'`: testo semplice, escapato in render.
|
||||
- `type = 'html'`: frammenti con markup minimo (grassetti, a-capo) sanificati con la pipeline già usata dal blog (`src/lib/sanitize.ts`). Usato solo dove il testo attuale contiene markup.
|
||||
- `type = 'image'`: `value` contiene il percorso dell'override caricato (es. `content/home-hero-1.jpg`, relativo a `uploads/`) oppure stringa vuota se vale l'asset originale.
|
||||
|
||||
### 3. Seed e fonte di verità
|
||||
|
||||
`src/data/content-seed.ts` contiene l'inventario completo dei tag con i valori estratti dai testi attuali: `{ tag, type, value, styleOptions }`. Il campo `styleOptions` dichiara quali preset ha senso esporre per quel tag (vedi §6); non finisce nel DB, guida solo il pannello.
|
||||
|
||||
All'avvio (in `createDb`) un passo di sync inserisce nel DB i tag presenti nel seed ma assenti nella tabella (`INSERT OR IGNORE`). Regole:
|
||||
|
||||
- In produzione il DB è la fonte di verità: le modifiche del cliente non vengono mai sovrascritte dal seed.
|
||||
- I tag nuovi arrivano con il codice (aggiunta al seed) e compaiono nel DB al deploy successivo.
|
||||
- I tag rimossi dal seed restano orfani nel DB e vengono ignorati; nessuna cancellazione automatica.
|
||||
- Il seed nel repo funge anche da backup versionato dei testi originali.
|
||||
|
||||
### 4. Rendering
|
||||
|
||||
- Le pagine vetrina (`index`, `about`, `contact`, `training`, `programs/index`, `programs/[slug]`, `404`) passano da `prerender = true` a `prerender = false` (SSR, già attivo per blog e admin con adapter node standalone).
|
||||
- Nuovo modulo `src/lib/content.ts`:
|
||||
- `getContent(tag)` / `getAllContent()`: lettura con cache in memoria (`Map` popolata al primo accesso con una sola query).
|
||||
- `invalidateContentCache()`: svuota la cache; chiamata dall'API di salvataggio. Modifica visibile alla richiesta successiva.
|
||||
- Fallback a cascata: valore nel DB → valore nel seed → stringa vuota con log di warning. Il sito non si rompe mai per un tag mancante.
|
||||
- Nuovi componenti Astro:
|
||||
- `<T tag="home.hero.title" as="h1" class="…" />`: renderizza il contenuto nel tag HTML indicato (`as`, default `span`), applica le classi preset derivate da `styles`, emette `data-tag` per l'overlay annotate. Per `type='text'` il valore è escapato; per `type='html'` è già sanificato al salvataggio.
|
||||
- `<TImg tag="home.hero.image" … />`: se esiste override in DB serve il file da `/uploads/content/…` (route `src/pages/uploads/[...path].ts` già esistente), altrimenti l'asset originale ottimizzato dalla pipeline Astro. Le prop di layout (dimensioni, alt di default, loading) restano nel codice.
|
||||
- La sitemap non cambia (le route restano le stesse); verificare che l'integrazione sitemap continui a emetterle con `output: 'server'` e prerender disattivato, altrimenti passare a una sitemap statica generata a mano.
|
||||
|
||||
### 5. Ruoli utente
|
||||
|
||||
- Migrazione additiva: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'` (eseguita in `createDb` se la colonna manca; gli utenti esistenti restano admin).
|
||||
- `getSessionUser` ritorna anche `role`; `context.locals.user` lo espone.
|
||||
- Middleware: le route `/admin/content` e `/api/admin/content*` sono accessibili ad `admin` ed `editor`; tutte le altre route `/admin*` e `/api/admin*` restano riservate ad `admin` (l'editor che le richiede riceve redirect a `/admin/content` o 403 sulle API).
|
||||
- Creazione utente editor: script CLI in `scripts/` (pattern già usato per l'admin), credenziali consegnate al cliente fuori banda.
|
||||
|
||||
### 6. Stili preset
|
||||
|
||||
Il DB memorizza solo chiavi simboliche; la mappa chiave → classe CSS vive nel codice (`src/lib/style-presets.ts` + classi in `global.css`). Nessun CSS arbitrario può entrare dal pannello.
|
||||
|
||||
| Gruppo | Chiavi | Note |
|
||||
| --- | --- | --- |
|
||||
| `size` | `s` / `m` / `l` | scala relativa alla dimensione base dell'elemento |
|
||||
| `color` | palette del sito (brand-dark, tan, taupe, bianco, …) | lista chiusa derivata dalle variabili CSS esistenti |
|
||||
| `weight` | `normal` / `bold` | |
|
||||
| `style` | `normal` / `italic` | |
|
||||
| `align` | `left` / `center` / `right` | solo su elementi a blocco |
|
||||
|
||||
Ogni tag dichiara nel seed quali gruppi espone (`styleOptions`): un titolo hero espone size e color, una voce di prezzo magari solo color. Valori non in whitelist vengono rifiutati dall'API al salvataggio.
|
||||
|
||||
### 7. Pannello `/admin/content`
|
||||
|
||||
Pagina SSR protetta, stessa shell di `src/layouts/Admin.astro`:
|
||||
|
||||
- Tag raggruppati per pagina (Home, About, Programs, Training, Contact, Globali), gruppi collassabili, campo di ricerca per nome tag o contenuto.
|
||||
- Per ogni tag: form con campo testo (input o textarea in base alla lunghezza), controlli preset solo per i gruppi dichiarati, upload immagine per i tag `image` (validazione mime/dimensione riusando la logica dell'upload blog) con anteprima e pulsante "ripristina originale".
|
||||
- Salvataggio per singolo tag (pulsante per riga) via `fetch` alle API; feedback inline di esito.
|
||||
- Ancore `id` per tag: `/admin/content#home.hero.title` scrolla ed evidenzia il form corrispondente.
|
||||
- Pulsante "Vedi sul sito con tag" che apre la pagina pubblica in modalità annotate.
|
||||
|
||||
API:
|
||||
|
||||
- `GET /api/admin/content` — elenco completo (per il pannello).
|
||||
- `PUT /api/admin/content/[tag]` — aggiorna `value` e/o `styles`; valida tipo, preset in whitelist, sanifica se `type='html'`; aggiorna `updated_at`/`updated_by`; invalida la cache.
|
||||
- `POST /api/admin/content/[tag]/image` — upload override immagine in `uploads/content/`; `DELETE` sullo stesso path rimuove l'override e ripristina l'asset originale.
|
||||
|
||||
### 8. Overlay annotate
|
||||
|
||||
- Attivazione: query string `?annotate=1` su qualunque pagina vetrina, solo con sessione valida (admin o editor). Senza sessione il parametro è ignorato: nessuna traccia dei tag per i visitatori.
|
||||
- `Base.astro` in modalità annotate inietta un piccolo CSS+JS vanilla (coerente con lo stile del progetto: niente librerie): ogni elemento con `data-tag` riceve un bordo tratteggiato e un badge con il nome del tag; il click sul badge apre `/admin/content#<tag>` in nuova scheda.
|
||||
- Le animazioni reveal restano attive; il badge è posizionato in modo da non alterare il layout (position absolute su wrapper relativo o outline, da verificare in implementazione).
|
||||
|
||||
### 9. Gestione errori
|
||||
|
||||
- Tag mancante nel DB e nel seed: stringa vuota + `console.warn` lato server. La pagina renderizza comunque.
|
||||
- `styles` JSON corrotto nel DB: si ignora e si usa `{}`.
|
||||
- Upload immagine: stessi limiti e messaggi del blog; file orfani in `uploads/content/` tollerati (nessuna garbage collection automatica).
|
||||
- API: 401 senza sessione, 403 per ruolo insufficiente, 400 con messaggio esplicito per tag inesistente, tipo errato o preset fuori whitelist.
|
||||
|
||||
### 10. Test
|
||||
|
||||
- Unit (`vitest`, pattern esistente in `tests/`): content lib (fallback a cascata, invalidazione cache, sync del seed), validazione preset, migrazione colonna `role`.
|
||||
- API: autorizzazione per ruolo (editor può contenuti, non può post; anonimo 401), salvataggio e rilettura, rifiuto preset non in whitelist.
|
||||
- Smoke: le pagine vetrina renderizzano in SSR con contenuti dal DB; overlay annotate presente con sessione e assente senza.
|
||||
|
||||
## Fasi di lavoro previste (indicative per il piano)
|
||||
|
||||
1. Infrastruttura: tabella, migrazione ruoli, content lib con cache, componenti `<T>`/`<TImg>`, preset.
|
||||
2. Estrazione e tagging: inventario completo testi/immagini pagina per pagina, creazione seed, sostituzione nei template e nei componenti (lavoro meccanico ma esteso; è la fase più lunga).
|
||||
3. Pannello + API + overlay annotate + ruolo editor.
|
||||
4. Test, verifica end-to-end locale, deploy e verifica in produzione.
|
||||
|
||||
## Fuori ambito
|
||||
|
||||
- CSS libero per elemento (escluso per scelta).
|
||||
- Versionamento/storico delle modifiche ai contenuti (eventuale evoluzione futura).
|
||||
- Modifica della struttura delle pagine (aggiunta/rimozione sezioni) dal pannello.
|
||||
- Gestione multilingua.
|
||||
- Blog: resta gestito dal suo admin TipTap esistente, fuori dal sistema tag.
|
||||
@@ -1,210 +0,0 @@
|
||||
# Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design
|
||||
|
||||
Data: 2026-07-05
|
||||
Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz)
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Portare il sito da due ruoli (`admin` | `editor`) a un sistema a tre ruoli con login pubblico,
|
||||
identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile
|
||||
per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay
|
||||
attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente.
|
||||
|
||||
## Ruoli e matrice permessi
|
||||
|
||||
Tre ruoli su `users.role`: `admin`, `superuser`, `user`. Il vecchio ruolo `editor` viene migrato a
|
||||
`superuser`.
|
||||
|
||||
| Capacità | user | superuser | admin |
|
||||
|---|:---:|:---:|:---:|
|
||||
| Login + nome nell'header + logout | ✓ | ✓ | ✓ |
|
||||
| Blog: CRUD **solo dei propri** articoli | ✓ | ✓ | ✓ |
|
||||
| Blog: gestione articoli **di tutti gli autori** | | ✓ | ✓ |
|
||||
| Pannello `/admin/content` | | ✓ | ✓ |
|
||||
| Toggle "Mostra tag" + modifica inline in pagina | | ✓ | ✓ |
|
||||
| Gestione utenti `/admin/users` (crea, reset psw, ruolo, elimina) | | | ✓ |
|
||||
|
||||
La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità.
|
||||
|
||||
## Modello dati (migrazioni idempotenti in `src/lib/db.ts`, prima di `syncSeed`)
|
||||
|
||||
Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione).
|
||||
|
||||
1. **Ruolo `editor` → `superuser`**
|
||||
`UPDATE users SET role = 'superuser' WHERE role = 'editor'`.
|
||||
Idempotente: al secondo giro nessuna riga `editor` resta.
|
||||
|
||||
2. **GUID contenuti** — colonna `content_blocks.guid TEXT`.
|
||||
- `ALTER TABLE content_blocks ADD COLUMN guid TEXT` se assente (PRAGMA table_info).
|
||||
- Backfill: per ogni riga con `guid IS NULL OR guid = ''`, assegna `crypto.randomUUID()`.
|
||||
- Indice univoco: `CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`
|
||||
(creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo).
|
||||
- `syncSeed` (in `db.ts` e in `content.ts`): l'INSERT delle righe seed nuove valorizza il guid con
|
||||
`crypto.randomUUID()` alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE),
|
||||
quindi il loro guid è quello del backfill.
|
||||
- Il **tag semantico resta la chiave primaria** e resta l'identificatore usato nel codice (`t('...')`,
|
||||
`<T tag="...">`) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno
|
||||
(attributo `data-guid` nel DOM, badge, eventuale API/link). Nessun tag cambia nome.
|
||||
|
||||
3. **Autore articoli** — colonna `posts.author_id INTEGER` (nullable).
|
||||
- `ALTER TABLE posts ADD COLUMN author_id INTEGER` se assente.
|
||||
- Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite
|
||||
sono solo su `sessions`); l'integrità è gestita nel codice.
|
||||
- Post storici: `author_id` resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico.
|
||||
|
||||
## Auth e sessioni (`src/lib/auth.ts`)
|
||||
|
||||
- `Role` diventa `'admin' | 'superuser' | 'user'`. `createUser` accetta i tre ruoli.
|
||||
- `SESSION_COOKIE`, `login`, `getSessionUser`, `logout` restano invariati.
|
||||
- Nuova funzione di autorizzazione che sostituisce `EDITOR_ALLOWED`/`canAccessAdminPath` con una
|
||||
**matrice esplicita per prefisso di rotta**:
|
||||
|
||||
```
|
||||
/admin/users, /api/admin/users* → admin
|
||||
/admin/content, /api/admin/content* → superuser, admin
|
||||
/admin (blog: index/new/edit/posts),
|
||||
/api/admin/posts*, /api/admin/upload → user, superuser, admin
|
||||
/admin/logout, /admin/login → chiunque (login pubblico)
|
||||
```
|
||||
|
||||
Firma proposta: `canAccessAdminPath(role, pathname): boolean`. La landing per redirect dipende dal
|
||||
ruolo (funzione `landingFor(role)`): `user → /admin` (lista blog), `superuser → /admin/content`,
|
||||
`admin → /admin`.
|
||||
- L'hashing password è già in `hashPassword`/`verifyPassword`; `scripts/create-user.mjs` continua a
|
||||
funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di `auth.ts`.
|
||||
|
||||
## Middleware (`src/middleware.ts`)
|
||||
|
||||
Struttura invariata (protegge `/admin` e `/api/admin`, esclude `/admin/login`). Cambia solo il ramo
|
||||
autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina →
|
||||
`redirect(landingFor(role))`; su una rotta API → `403` JSON. `context.locals.user` continua a esporre
|
||||
`{ id, username, role }`.
|
||||
|
||||
## Login pubblico e header
|
||||
|
||||
### Pagina `/login` (`src/pages/login.astro`)
|
||||
- Layout `Base` (stile sito pubblico), **non** `Admin`.
|
||||
- Form username+password. Riusa la logica di `/admin/login`: stesso `rateLimit`, stesso `login()`,
|
||||
stesso set del cookie `session` (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni).
|
||||
- Query `?next=<path>`: dopo login valido redirige a `next` se è un path interno sicuro (inizia con `/`,
|
||||
non `//`, non `/api/`); altrimenti alla home `/`.
|
||||
- Se già loggato: redirige a `next` sicuro o a `/`.
|
||||
- `/admin/login` resta come alias funzionante (nessuna rimozione).
|
||||
|
||||
### Header (`src/components/Header.astro`, già incluso da `Base.astro`)
|
||||
Reso SSR con lettura sessione dal cookie:
|
||||
- **Anonimo**: link "Accedi" → `/login?next=<pagina-corrente>`.
|
||||
- **Loggato**: nome utente + menu a tendina:
|
||||
- "Area riservata" → `landingFor(role)`.
|
||||
- Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva.
|
||||
- "Esci" → `/admin/logout`.
|
||||
- Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie.
|
||||
|
||||
## Toggle "Mostra tag" e modifica inline
|
||||
|
||||
### Attivazione
|
||||
- Cookie `showtags=1` (non httpOnly, così lo script client può leggerlo; path `/`), impostato/rimosso
|
||||
dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica
|
||||
il ruolo).
|
||||
- `Base.astro` calcola `showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin` e
|
||||
passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più `?annotate=1` da
|
||||
trascinare nell'URL).
|
||||
- Compat: `?annotate=1` continua a funzionare attivando il cookie/flag per la richiesta corrente.
|
||||
|
||||
### Badge
|
||||
- Quando `showTags` è attivo, lo script badge esistente in `Base.astro` (overlay assoluto sugli
|
||||
elementi con `data-tag`) viene esteso per: mostrare il **nome tag**, esporre il **`data-guid`**, e
|
||||
aggiungere un pulsante matita ✎ cliccabile.
|
||||
- Nessun badge per i visitatori anonimi o per il ruolo `user` (lo script e il markup non vengono
|
||||
nemmeno emessi).
|
||||
|
||||
### Modal di modifica inline
|
||||
Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al
|
||||
pannello):
|
||||
- **Nuovo endpoint** `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }`
|
||||
(`styleOptions` da `seedByTag`/style-presets, come già fa `GET /api/admin/content`).
|
||||
- Il form si costruisce in base a `type`:
|
||||
- `text` / `html` → textarea + eventuali select di stile dagli `styleOptions`; salva con
|
||||
**`PUT /api/admin/content/[tag]`** (esistente, ritorna `{ ok, value }` post-sanitizzazione).
|
||||
- `image` → input file → **`POST /api/admin/content/[tag]/image`** (esistente) e rimozione via
|
||||
**`DELETE`** (esistente).
|
||||
- Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal `value` ritornato) e si
|
||||
chiude, senza reload. Errori mostrati dentro il modal.
|
||||
- Il codice del modal (markup + script) è caricato **solo** quando `showTags` è attivo: zero peso per
|
||||
chi non è superuser/admin con toggle attivo.
|
||||
|
||||
### Riuso vs nuovo
|
||||
Le API di scrittura contenuti (PUT, POST/DELETE image) restano **invariate** — sono le stesse usate dal
|
||||
pannello `/admin/content`. L'unica aggiunta backend è il `GET /api/admin/content/[tag]` singolo. Il
|
||||
modal è l'unico componente frontend nuovo.
|
||||
|
||||
## Gestione utenti (`/admin/users`, solo admin)
|
||||
|
||||
### Pagina `src/pages/admin/users.astro` (layout `Admin`)
|
||||
Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per:
|
||||
crea, reset password, cambia ruolo, elimina.
|
||||
|
||||
### API `src/pages/api/admin/users/*` (admin-only via middleware)
|
||||
- **`POST /api/admin/users`** → crea utente: `username` + `password` + `role`
|
||||
(`user`|`superuser`|`admin`). Rifiuta username duplicato (409/400 con messaggio). Usa `createUser`.
|
||||
- **`POST /api/admin/users/[id]/reset-password`** → genera password casuale, la ritorna **una sola
|
||||
volta** in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiorna `password_hash`.
|
||||
Non viene mai ri-mostrata né loggata.
|
||||
- **`PUT /api/admin/users/[id]/role`** → cambia ruolo.
|
||||
- **`DELETE /api/admin/users/[id]`** → elimina l'utente.
|
||||
|
||||
### Guardie (invarianti di sicurezza)
|
||||
- Non puoi **eliminare te stesso** (confronto con `locals.user.id`).
|
||||
- Non puoi eliminare **l'ultimo admin** rimasto, né declassarne il ruolo se è l'ultimo admin
|
||||
(query di conteggio `role='admin'` nella stessa transazione).
|
||||
- Eliminazione utente → cascade sulle sue sessioni (già `ON DELETE CASCADE` su `sessions.user_id`).
|
||||
Gli articoli con `author_id` di un utente eliminato: `author_id` resta valorizzato ma "orfano" →
|
||||
firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post).
|
||||
|
||||
## Blog ownership
|
||||
|
||||
### API `/api/admin/posts*` e `/api/admin/posts/[id]`
|
||||
- **`user`**: la lista in `/admin` è filtrata sui propri `author_id`; `POST` forza
|
||||
`author_id = locals.user.id`; `PUT`/`DELETE` su un post con `author_id` diverso → **403**.
|
||||
- **`superuser` / `admin`**: vedono e gestiscono tutti i post; il `POST` valorizza comunque
|
||||
`author_id = locals.user.id` (l'autore è chi crea).
|
||||
- Implementazione: `createPost` accetta `author_id`; `updatePost`/`deletePost` restano invariati ma le
|
||||
route caricano il post e verificano la ownership prima di procedere (già caricano via `getPostById`
|
||||
su PUT; aggiungere il caricamento su DELETE che oggi non lo fa).
|
||||
|
||||
### Pannello blog (`/admin`, `/admin/new`, `/admin/edit/[id]`)
|
||||
- `user` vede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore".
|
||||
- Editing di un post altrui bloccato lato UI per `user` (e comunque 403 lato API).
|
||||
|
||||
### Blog pubblico
|
||||
- Firma autore sotto il titolo dell'articolo: nome dell'utente `author_id`; fallback
|
||||
"Staff InsanityLab" quando `author_id` è nullo o orfano.
|
||||
|
||||
## Gestione errori
|
||||
|
||||
- API: JSON `{ error }` con status coerente (400 input non valido, 401 non autenticato — dal
|
||||
middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati).
|
||||
- `next` non sicuro nel login → ignorato, redirect a `/` (niente open redirect).
|
||||
- Cookie `showtags` presente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side
|
||||
con il ruolo reale, il cookie da solo non basta).
|
||||
- Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`tests/roles.test.ts` esteso): matrice `canAccessAdminPath` per i tre ruoli su tutti i
|
||||
prefissi; `landingFor`.
|
||||
- **Unit ownership**: `user` non può PUT/DELETE post altrui; `POST` imposta `author_id`; guardie
|
||||
gestione utenti (no self-delete, no last-admin delete/demote).
|
||||
- **Unit migrazioni**: su DB in-memory con righe `editor` e `content_blocks` senza guid → dopo
|
||||
`createDb`: ruoli migrati, guid non nulli e univoci, colonna `posts.author_id` presente.
|
||||
- **Smoke** (`scripts/smoke.sh` esteso): `/login` 200; header mostra "Accedi" da anonimo; rotte admin →
|
||||
redirect/403 da `user`; badge con `data-guid` presenti da superuser+ con toggle; modal/badge assenti
|
||||
da anonimo.
|
||||
|
||||
## Fuori scope (YAGNI)
|
||||
|
||||
- Nessun framework/reattività frontend (no Alpine/petite-vue).
|
||||
- Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice.
|
||||
- Nessuna registrazione self-service: gli utenti li crea l'admin.
|
||||
- Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce.
|
||||
- Nessuna gestione avatar/profilo utente oltre username + ruolo.
|
||||
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 192 KiB |
@@ -20,9 +20,15 @@ const { variant = 'full' } = Astro.props;
|
||||
<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"><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) => {
|
||||
|
||||
@@ -11,9 +11,10 @@ const year = new Date().getFullYear();
|
||||
<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.phone')}<br />{t('global.contact.address')}, {t('global.contact.city-line')}</p>
|
||||
<p>{t('global.contact.address')}, {t('global.contact.city-line')}<br />{t('global.contact.phone')}<br /><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
|
||||
<p class="ftr__hours">{t('global.hours.1')}</p>
|
||||
<p class="ftr__hours">{t('global.hours.2')}</p>
|
||||
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
|
||||
</div>
|
||||
<nav aria-label="training">
|
||||
<T tag="footer.col.training-title" as="h4" />
|
||||
@@ -30,7 +31,7 @@ const year = new Date().getFullYear();
|
||||
</div>
|
||||
<div class="ftr__bar">
|
||||
<div class="container ftr__bar-in">
|
||||
<p>© {year} <T tag="footer.copyright" as="span" /></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>
|
||||
@@ -44,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; }
|
||||
|
||||
@@ -35,7 +35,9 @@ const slides = [
|
||||
/* 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)); }
|
||||
.hero__line { display: block; }
|
||||
/* 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 { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
|
||||
@@ -50,14 +52,14 @@ const slides = [
|
||||
/* 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>
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
|
||||
.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); }
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
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">
|
||||
@@ -10,7 +15,7 @@ import T from '../content/T.astro';
|
||||
<T tag="home.services.body" as="p" />
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{services.map((p, i) => (
|
||||
{gridServices.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
|
||||
@@ -11,9 +11,9 @@ 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']): SeedEntry =>
|
||||
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']): SeedEntry =>
|
||||
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: '' });
|
||||
|
||||
@@ -23,6 +23,8 @@ export const contentSeed: SeedEntry[] = [
|
||||
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, []),
|
||||
@@ -278,10 +280,6 @@ export const contentSeed: SeedEntry[] = [
|
||||
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.'),
|
||||
T('training.trial.title', 'Prenota la lezione di prova'),
|
||||
T('training.trial.body', "La lezione di prova è l'occasione perfetta per conoscere il metodo, vivere l'esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova."),
|
||||
T('training.trial.cta', 'Prenota', []),
|
||||
I('training.trial.image'),
|
||||
|
||||
// --- Contact ---
|
||||
T('contact.meta.title', 'Contatti', []),
|
||||
@@ -302,6 +300,79 @@ export const contentSeed: SeedEntry[] = [
|
||||
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,9 +1,9 @@
|
||||
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
|
||||
export const serviceIcons: Record<string, string> = {
|
||||
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'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',
|
||||
'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',
|
||||
// Servizi rinominati (modifiche 02): profilazione (grafico/analisi), osteopatia (mani)
|
||||
|
||||
@@ -2,21 +2,22 @@ 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: "Servizi", href: "/services" },
|
||||
{ label: "About us", href: "/about" },
|
||||
{ label: "Blog & Edugo", href: "/blog" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
],
|
||||
footerTraining: [
|
||||
{ label: "One to one", href: "/training#one-to-one" },
|
||||
|
||||
@@ -11,8 +11,6 @@ export const team: Member[] = [
|
||||
bio: "Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." },
|
||||
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, imageAbout: "team-eleonora-dark", image: "team-eleonora",
|
||||
bio: "Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
|
||||
{ name: "Antonello D’Alessandro", shortName: "Antonello", role: "Trainer – Exercise & Health", founder: false, imageAbout: "team-antonello-dark", 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, imageAbout: "team-alice-dark",
|
||||
bio: "Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso." },
|
||||
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-davide-dark",
|
||||
@@ -21,4 +19,4 @@ export const team: Member[] = [
|
||||
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"];
|
||||
|
||||
@@ -19,10 +19,17 @@ 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>
|
||||
@@ -36,6 +43,9 @@ const showTags = canTags && wantTags;
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
{gtmId && (
|
||||
<noscript set:html={`<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
|
||||
)}
|
||||
<Header user={user} showTags={showTags} canTags={canTags} />
|
||||
<main><slot /></main>
|
||||
<Footer />
|
||||
@@ -77,37 +87,108 @@ const showTags = canTags && wantTags;
|
||||
<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; }
|
||||
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; }
|
||||
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
|
||||
.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: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
|
||||
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
|
||||
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
|
||||
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
|
||||
.tag-modal__box select { margin: 6px 8px 6px 0; }
|
||||
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
|
||||
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
|
||||
.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__cancel { background: #ddd; }
|
||||
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
|
||||
.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">
|
||||
<h3 id="tm-tag"></h3>
|
||||
<p class="guid" id="tm-guid"></p>
|
||||
<div id="tm-body"></div>
|
||||
<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__save" id="tm-save">Salva</button>
|
||||
<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'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] };
|
||||
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');
|
||||
@@ -137,8 +218,7 @@ const showTags = canTags && wantTags;
|
||||
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.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
|
||||
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' });
|
||||
@@ -148,18 +228,52 @@ const showTags = canTags && wantTags;
|
||||
const ta = document.createElement('textarea');
|
||||
ta.id = 'tm-value'; ta.value = data.value;
|
||||
tmBody.appendChild(ta);
|
||||
(data.styleOptions || []).forEach((group) => {
|
||||
const opts = STYLE_GROUPS[group];
|
||||
if (!opts) return;
|
||||
const sel = document.createElement('select');
|
||||
sel.dataset.group = group;
|
||||
sel.className = 'tm-style';
|
||||
const none = document.createElement('option');
|
||||
none.value = ''; none.textContent = group + ': —';
|
||||
sel.appendChild(none);
|
||||
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
|
||||
tmBody.appendChild(sel);
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -176,10 +290,16 @@ const showTags = canTags && wantTags;
|
||||
return;
|
||||
}
|
||||
const value = document.getElementById('tm-value').value;
|
||||
const selects = document.querySelectorAll('.tm-style');
|
||||
const styles = {};
|
||||
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
|
||||
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 (Object.keys(styles).length) body.styles = styles;
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -150,6 +150,26 @@ export function createDb(path?: string): Database.Database {
|
||||
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).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const STYLE_GROUPS = {
|
||||
size: ['s', 'm', 'l'],
|
||||
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'],
|
||||
align: ['left', 'center', 'right', 'justify'],
|
||||
} as const;
|
||||
|
||||
export type StyleGroup = keyof typeof STYLE_GROUPS;
|
||||
|
||||
@@ -149,15 +149,21 @@ export const prerender = false;
|
||||
.conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
|
||||
.round-img { max-width: 380px; margin: 30px auto 0; }
|
||||
.round-img img { border-radius: 50%; aspect-ratio: 1; object-fit: cover; width: 100%; }
|
||||
.tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
|
||||
.tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; }
|
||||
/* pre-line preserva gli a-capo del testo (il <p> reso da <T> non eredita il cid → :global). */
|
||||
.about-img-row__text :global([data-tag="about.studios.body3"]) { white-space: pre-line; }
|
||||
/* flex + center così l'ultima riga incompleta (es. staff: 5 membri → 2 nell'ultima riga)
|
||||
resta centrata invece di appoggiarsi a sinistra. */
|
||||
.tgrid { display: flex; flex-wrap: wrap; justify-content: center; gap: 40px; }
|
||||
.tcard { width: calc((100% - 80px) / 3); }
|
||||
/* :global(img) perché gli <img> di override resi da <TImg> non ereditano il cid. */
|
||||
.tcard :global(img) { aspect-ratio: 3/4; object-fit: cover; width: 100%; margin-bottom: 16px; }
|
||||
.tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
|
||||
.tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
||||
.tcard p { font-size: .9rem; }
|
||||
@media (max-width: 767px) {
|
||||
.about-2col,
|
||||
.tgrid,
|
||||
.about-img-row { grid-template-columns: 1fr; }
|
||||
.tcard { width: 100%; }
|
||||
.about-img-row--reverse { direction: ltr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,14 +10,20 @@ const json = (status: number, body: object) =>
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const tag = params.tag ?? '';
|
||||
const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as
|
||||
{ tag: string; type: string; value: string; guid: string | null } | undefined;
|
||||
const row = getDb().prepare('SELECT tag, type, value, styles, guid FROM content_blocks WHERE tag = ?').get(tag) as
|
||||
{ tag: string; type: string; value: string; styles: string; guid: string | null } | undefined;
|
||||
if (!row) return json(404, { error: 'Tag inesistente.' });
|
||||
let styles: Record<string, string> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(row.styles);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) styles = parsed;
|
||||
} catch { /* styles corrotto: si ignora */ }
|
||||
return json(200, {
|
||||
tag: row.tag,
|
||||
guid: row.guid ?? '',
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
styles,
|
||||
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,15 +6,18 @@ import contactHero from '../assets/img/contact-hero.jpg';
|
||||
import T from '../components/content/T.astro';
|
||||
import { t, getImageOverride } from '../lib/content';
|
||||
export const prerender = false;
|
||||
const mapsQuery = encodeURIComponent(`${t('global.contact.address')}, ${t('global.contact.city-line')}`);
|
||||
const mapsQuery = encodeURIComponent(t('global.contact.map-query'));
|
||||
---
|
||||
<Base title={t('contact.meta.title')} description={t('contact.meta.description')}>
|
||||
<PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} />
|
||||
<section class="section">
|
||||
<div class="container cgrid">
|
||||
<div class="cmap" id="map-box">
|
||||
<button class="btn btn--dark" id="map-load">{t('contact.map.button')}</button>
|
||||
<p class="cmap__note">{t('contact.map.note')}</p>
|
||||
<div class="cmap" id="map-box" data-maps-query={mapsQuery}>
|
||||
<div class="cmap__consent">
|
||||
<p class="cmap__title">Mappa Google</p>
|
||||
<p class="cmap__note">Caricando la mappa accetti che Google riceva i tuoi dati di connessione (incluso l'indirizzo IP) secondo la sua informativa. Vedi la nostra <a href="/cookie-policy">Cookie Policy</a>.</p>
|
||||
<button class="btn btn--dark" id="map-load" type="button">Carica la mappa</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContactForm variant="full" />
|
||||
</div>
|
||||
@@ -40,18 +43,24 @@ const mapsQuery = encodeURIComponent(`${t('global.contact.address')}, ${t('globa
|
||||
|
||||
<style>
|
||||
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
|
||||
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
|
||||
.cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
|
||||
.cmap__note { font-size: .8rem; color: var(--c-text-light); }
|
||||
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; align-items: center; justify-content: center; }
|
||||
.cmap iframe { display: block; width: 100%; height: 100%; min-height: 420px; border: 0; }
|
||||
.cmap__consent { max-width: 380px; text-align: center; padding: 24px; display: flex; flex-direction: column; gap: 12px; align-items: center; }
|
||||
.cmap__title { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .12em; font-size: .9rem; margin: 0; }
|
||||
.cmap__note { font-size: .8rem; color: var(--c-text-light); margin: 0; line-height: 1.5; }
|
||||
.cmap__note a { color: var(--c-accent-dark); }
|
||||
.cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
|
||||
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
|
||||
.cinvite { max-width: 640px; }
|
||||
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
<script define:vars={{ mapsQuery }}>
|
||||
<script>
|
||||
// GDPR: la mappa Google (terza parte) viene caricata solo dopo consenso esplicito.
|
||||
document.getElementById('map-load')?.addEventListener('click', () => {
|
||||
const box = document.getElementById('map-box');
|
||||
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
|
||||
if (!box) return;
|
||||
const q = box.dataset.mapsQuery ?? '';
|
||||
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${q}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade" style="display:block;width:100%;height:100%;min-height:420px;border:0"></iframe>`;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Cookie Policy — InsanityLab" description="Informativa sull'uso dei cookie del sito insanitylab.it.">
|
||||
<PageHero title="Cookie Policy" />
|
||||
<section class="section">
|
||||
<div class="container legal">
|
||||
<p class="legal__updated">Ultimo aggiornamento: luglio 2026</p>
|
||||
|
||||
<p>La presente Cookie Policy descrive i cookie e le tecnologie analoghe utilizzati dal sito <strong>insanitylab.it</strong>, gestito da <strong>IN-SANITY LAB S.R.L.</strong> (Via Leandro Alberti 76, 40139 Bologna — P. IVA 03927341200).</p>
|
||||
|
||||
<h2>1. Cosa sono i cookie</h2>
|
||||
<p>I cookie sono piccoli file di testo che i siti visitati inviano al dispositivo dell'utente, dove vengono memorizzati per essere ritrasmessi agli stessi siti alla visita successiva. Possono avere finalità tecniche, funzionali o di profilazione.</p>
|
||||
|
||||
<h2>2. Cookie utilizzati da questo sito</h2>
|
||||
<p>Il sito utilizza <strong>esclusivamente cookie tecnici</strong>, necessari al funzionamento dell'area riservata. Questi cookie non richiedono il consenso dell'utente e non vengono impostati ai visitatori non autenticati.</p>
|
||||
<div class="legal__table">
|
||||
<table>
|
||||
<thead><tr><th>Cookie</th><th>Finalità</th><th>Durata</th><th>Tipo</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>session</td><td>Mantiene l'autenticazione dell'utente nell'area riservata</td><td>Sessione</td><td>Tecnico</td></tr>
|
||||
<tr><td>showtags</td><td>Ricorda la preferenza di visualizzazione dei tag di modifica dei contenuti (solo utenti autorizzati)</td><td>Sessione</td><td>Tecnico / funzionale</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>Il sito <strong>non utilizza cookie di profilazione, di marketing o di analisi statistica</strong> (nessun Google Analytics o strumento equivalente).</p>
|
||||
|
||||
<h2>3. Contenuti di terze parti — Google Maps</h2>
|
||||
<p>La pagina Contatti può mostrare una mappa fornita da <strong>Google Maps</strong>. Per tutelare la tua privacy, la mappa <strong>non viene caricata automaticamente</strong>: compare solo dopo che hai cliccato l'apposito pulsante di consenso. Fino a quel momento nessun dato viene inviato a Google. Se scegli di caricare la mappa, Google può impostare propri cookie e trattare dati (incluso l'indirizzo IP) secondo la propria <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">informativa privacy</a>.</p>
|
||||
|
||||
<h2>4. Gestione dei cookie dal browser</h2>
|
||||
<p>L'utente può gestire o eliminare i cookie tramite le impostazioni del proprio browser. La disabilitazione dei cookie tecnici può compromettere il funzionamento dell'area riservata. Le istruzioni sono disponibili nelle guide dei principali browser (Chrome, Firefox, Safari, Edge).</p>
|
||||
|
||||
<h2>5. Titolare e contatti</h2>
|
||||
<p>Per qualsiasi richiesta relativa ai cookie e al trattamento dei dati è possibile scrivere a <a href="mailto:info@insanitylab.it">info@insanitylab.it</a>. Per maggiori informazioni sul trattamento dei dati personali si rimanda alla <a href="/privacy">Privacy Policy</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal { max-width: 760px; }
|
||||
.legal__updated { font-size: .8rem; color: var(--c-text-light); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 30px; }
|
||||
.legal h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: .08em; margin: 34px 0 12px; }
|
||||
.legal p, .legal li { line-height: 1.7; }
|
||||
.legal a { color: var(--c-accent-dark); }
|
||||
.legal__table { overflow-x: auto; margin: 12px 0; }
|
||||
.legal table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.legal th, .legal td { border: 1px solid var(--c-border, #e4ded6); padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
.legal th { background: var(--c-bg-alt); text-transform: uppercase; font-size: .75rem; letter-spacing: .06em; }
|
||||
</style>
|
||||
@@ -6,8 +6,6 @@ import FeatureBlocks from '../components/home/FeatureBlocks.astro';
|
||||
import QuoteBanner from '../components/home/QuoteBanner.astro';
|
||||
import TrainingCards from '../components/home/TrainingCards.astro';
|
||||
import ServicesGrid from '../components/home/ServicesGrid.astro';
|
||||
import Agenda from '../components/Agenda.astro';
|
||||
import TeamSection from '../components/home/TeamSection.astro';
|
||||
import MethodSteps from '../components/home/MethodSteps.astro';
|
||||
import PartnersStrip from '../components/home/PartnersStrip.astro';
|
||||
import T from '../components/content/T.astro';
|
||||
@@ -27,8 +25,6 @@ export const prerender = false;
|
||||
</section>
|
||||
<TrainingCards />
|
||||
<ServicesGrid />
|
||||
<Agenda />
|
||||
<TeamSection />
|
||||
<MethodSteps />
|
||||
<PartnersStrip />
|
||||
</Base>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Privacy Policy — InsanityLab" description="Informativa sul trattamento dei dati personali ai sensi del Regolamento (UE) 2016/679 (GDPR).">
|
||||
<PageHero title="Privacy Policy" />
|
||||
<section class="section">
|
||||
<div class="container legal">
|
||||
<p class="legal__updated">Ultimo aggiornamento: luglio 2026</p>
|
||||
|
||||
<p>La presente informativa descrive le modalità di trattamento dei dati personali degli utenti che consultano il sito <strong>insanitylab.it</strong> e che utilizzano il modulo di contatto, ai sensi dell'articolo 13 del Regolamento (UE) 2016/679 (di seguito "GDPR").</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>
|
||||
<strong>IN-SANITY LAB S.R.L.</strong><br>
|
||||
Via Leandro Alberti 76, 40139 Bologna (BO)<br>
|
||||
P. IVA e Codice Fiscale: 03927341200 — REA: BO-556474<br>
|
||||
E-mail: <a href="mailto:info@insanitylab.it">info@insanitylab.it</a> — PEC: insanitylab@pec.it<br>
|
||||
Telefono: 351 7535977
|
||||
</p>
|
||||
|
||||
<h2>2. Tipologie di dati trattati</h2>
|
||||
<p>Il sito tratta le seguenti categorie di dati:</p>
|
||||
<ul>
|
||||
<li><strong>Dati forniti volontariamente tramite il modulo di contatto</strong>: nome, cognome, indirizzo e-mail, numero di telefono (facoltativo) e il testo del messaggio.</li>
|
||||
<li><strong>Dati di navigazione</strong>: dati tecnici trasmessi automaticamente dal browser (ad esempio indirizzo IP e log del server), trattati per il funzionamento e la sicurezza del sito.</li>
|
||||
</ul>
|
||||
<p>Il sito <strong>non utilizza strumenti di analisi statistica o di profilazione</strong> (es. Google Analytics) e non installa cookie di marketing.</p>
|
||||
|
||||
<h2>3. Finalità e base giuridica</h2>
|
||||
<ul>
|
||||
<li><strong>Riscontro alle richieste inviate tramite il modulo di contatto</strong> — base giuridica: esecuzione di misure precontrattuali richieste dall'interessato e legittimo interesse del Titolare a rispondere (art. 6.1 lett. b e f GDPR).</li>
|
||||
<li><strong>Funzionamento e sicurezza del sito</strong> — base giuridica: legittimo interesse del Titolare (art. 6.1 lett. f GDPR).</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Modalità e periodo di conservazione</h2>
|
||||
<p>I dati sono trattati con strumenti elettronici e con misure di sicurezza adeguate. I messaggi ricevuti tramite il modulo di contatto vengono conservati per il tempo necessario a gestire la richiesta e a dare seguito agli eventuali rapporti che ne derivano, e comunque non oltre i termini previsti dalla normativa applicabile. I log tecnici del server sono conservati per il tempo strettamente necessario alle finalità di sicurezza.</p>
|
||||
|
||||
<h2>5. Destinatari e responsabili del trattamento</h2>
|
||||
<p>I dati possono essere trattati da soggetti che forniscono servizi al Titolare in qualità di responsabili del trattamento, in particolare:</p>
|
||||
<ul>
|
||||
<li><strong>Aruba S.p.A.</strong> — fornitore dei servizi di posta elettronica utilizzati per l'invio e la ricezione dei messaggi del modulo di contatto.</li>
|
||||
<li>Il fornitore di hosting che ospita il sito.</li>
|
||||
</ul>
|
||||
<p>I dati non sono diffusi né ceduti a terzi per finalità diverse da quelle indicate.</p>
|
||||
|
||||
<h2>6. Servizi di terze parti e trasferimenti extra UE</h2>
|
||||
<p>La pagina Contatti può visualizzare una mappa fornita da <strong>Google Maps</strong> (Google Ireland Ltd. / Google LLC). La mappa viene caricata <strong>solo previo consenso esplicito</strong> dell'utente, tramite apposito pulsante: fino a quel momento nessun dato viene trasmesso a Google. In caso di caricamento, Google può trattare dati (incluso l'indirizzo IP), anche verso Paesi terzi, secondo la propria informativa privacy. Maggiori dettagli nella <a href="/cookie-policy">Cookie Policy</a>.</p>
|
||||
|
||||
<h2>7. Diritti dell'interessato</h2>
|
||||
<p>L'interessato può in qualsiasi momento esercitare i diritti previsti dagli articoli da 15 a 22 del GDPR: accesso, rettifica, cancellazione, limitazione, portabilità, opposizione al trattamento e revoca del consenso (ove applicabile). Le richieste possono essere inviate a <a href="mailto:info@insanitylab.it">info@insanitylab.it</a>. L'interessato ha inoltre il diritto di proporre reclamo all'Autorità Garante per la protezione dei dati personali (<a href="https://www.garanteprivacy.it" target="_blank" rel="noopener">www.garanteprivacy.it</a>).</p>
|
||||
|
||||
<h2>8. Cookie</h2>
|
||||
<p>Il sito utilizza esclusivamente cookie tecnici necessari al funzionamento dell'area riservata. Per il dettaglio si rimanda alla <a href="/cookie-policy">Cookie Policy</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal { max-width: 760px; }
|
||||
.legal__updated { font-size: .8rem; color: var(--c-text-light); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 30px; }
|
||||
.legal h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: .08em; margin: 34px 0 12px; }
|
||||
.legal p, .legal li { line-height: 1.7; }
|
||||
.legal ul { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 8px; }
|
||||
.legal a { color: var(--c-accent-dark); }
|
||||
</style>
|
||||
@@ -1,148 +1,88 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import T from '../components/content/T.astro';
|
||||
import { t, resolveContent } from '../lib/content';
|
||||
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
|
||||
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account,
|
||||
// inserisce i dati e paga via Stripe dentro la piattaforma. Link centralizzato qui:
|
||||
// quando avremo gli ID membership per singolo prodotto basterà mappare BSPORT_URL card per card.
|
||||
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
|
||||
export const prerender = false;
|
||||
|
||||
// I testi sono editabili dal pannello contenuti (tag `promo.*`). Qui resta solo la
|
||||
// struttura: quante card, quante feature ciascuna e quale è "in evidenza".
|
||||
const cards = [
|
||||
{ id: 1, features: 4, featured: false },
|
||||
{ id: 2, features: 4, featured: true },
|
||||
{ id: 3, features: 3, featured: false },
|
||||
{ id: 4, features: 3, featured: false },
|
||||
];
|
||||
const faqs = [1, 2, 3, 4, 5, 6];
|
||||
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
|
||||
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
|
||||
const wa = t('global.whatsapp');
|
||||
---
|
||||
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
|
||||
<div class="lp">
|
||||
<div class="page">
|
||||
|
||||
<section class="hero">
|
||||
<div class="badge">insanity.lab · bologna savena</div>
|
||||
<h1>Allenati con metodo.</h1>
|
||||
<p class="accent">Scegli come</p>
|
||||
<h1>vuoi farlo.</h1>
|
||||
<p class="desc">Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.</p>
|
||||
<a class="btn-sand" href="#proposte">LE NOSTRE PROPOSTE →</a>
|
||||
<T tag="promo.hero.badge" as="div" class="badge" />
|
||||
<T tag="promo.hero.title1" as="h1" />
|
||||
<T tag="promo.hero.accent" as="p" class="accent" />
|
||||
<T tag="promo.hero.title2" as="h1" />
|
||||
<T tag="promo.hero.desc" as="p" class="desc" />
|
||||
<a class="btn-sand" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
|
||||
</section>
|
||||
|
||||
<section class="launch">
|
||||
<div class="badge">solo per il lancio</div>
|
||||
<h1>Il nostro metodo nasce per durare.</h1>
|
||||
<p class="accent">Per l'apertura, un'eccezione. Solo per poco.</p>
|
||||
<p class="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.</p>
|
||||
<T tag="promo.launch.badge" as="div" class="badge" />
|
||||
<T tag="promo.launch.title" as="h1" />
|
||||
<T tag="promo.launch.accent" as="p" class="accent" />
|
||||
<T tag="promo.launch.desc" as="p" class="desc" />
|
||||
</section>
|
||||
|
||||
<section class="why5">
|
||||
<p>Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.</p>
|
||||
<T tag="promo.why5.text" as="p" />
|
||||
</section>
|
||||
|
||||
<section class="cards-wrap" id="proposte">
|
||||
<div class="cards">
|
||||
|
||||
<div class="card">
|
||||
<div class="eyebrow">Personal training</div>
|
||||
<div class="title">One to One</div>
|
||||
<div class="price-row">
|
||||
<div class="price">163 €</div>
|
||||
<div class="price-old">300 €</div>
|
||||
{cards.map((c) => (
|
||||
<div class:list={['card', c.featured && 'featured']}>
|
||||
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
|
||||
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
|
||||
<T tag={`promo.card.${c.id}.title`} as="div" class="title" />
|
||||
<div class="price-row">
|
||||
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
|
||||
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
|
||||
</div>
|
||||
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
|
||||
<ul>
|
||||
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
|
||||
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
|
||||
))}
|
||||
</ul>
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
|
||||
</div>
|
||||
<div class="unit">5 incontri</div>
|
||||
<ul>
|
||||
<li>Screening iniziale, definizione dei tuoi obiettivi</li>
|
||||
<li>Check-up con profilazione fisica, il tuo punto di partenza</li>
|
||||
<li>PT dedicato, presente a ogni sessione</li>
|
||||
<li>In-sanity plan, il tuo programma personalizzato</li>
|
||||
</ul>
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
|
||||
</div>
|
||||
|
||||
<div class="card featured">
|
||||
<div class="featured-badge">Più scelto</div>
|
||||
<div class="eyebrow">Personal training</div>
|
||||
<div class="title">Small Group</div>
|
||||
<div class="price-row">
|
||||
<div class="price">110 €</div>
|
||||
<div class="price-old">220 €</div>
|
||||
</div>
|
||||
<div class="unit">5 incontri</div>
|
||||
<ul>
|
||||
<li>Screening iniziale, definizione dei tuoi obiettivi</li>
|
||||
<li>Check-up con profilazione fisica, il tuo punto di partenza</li>
|
||||
<li>PT dedicato, presente a ogni sessione</li>
|
||||
<li>In-sanity plan, il tuo programma personalizzato</li>
|
||||
</ul>
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="eyebrow">Pilates class</div>
|
||||
<div class="title">Reformer</div>
|
||||
<div class="price-row">
|
||||
<div class="price">100 €</div>
|
||||
<div class="price-old">140 €</div>
|
||||
</div>
|
||||
<div class="unit">5 incontri</div>
|
||||
<ul>
|
||||
<li>Core, postura e mobilità su macchina Reformer</li>
|
||||
<li>Lavoro a basso impatto sulle articolazioni</li>
|
||||
<li>Percorso breve, non lezioni isolate</li>
|
||||
</ul>
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="eyebrow">Holistic class</div>
|
||||
<div class="title">Pilates Mat, Mobility, Yoga</div>
|
||||
<div class="price-row">
|
||||
<div class="price">50 €</div>
|
||||
<div class="price-old">100 €</div>
|
||||
</div>
|
||||
<div class="unit">5 incontri</div>
|
||||
<ul>
|
||||
<li>Mobilità articolare, respirazione, equilibrio</li>
|
||||
<li>Da solo, o integrato nei tuoi allenamenti</li>
|
||||
<li>Adatto a tutti i livelli</li>
|
||||
</ul>
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
|
||||
</div>
|
||||
|
||||
))}
|
||||
</div>
|
||||
<p class="validity-note">Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026</p>
|
||||
<T tag="promo.validity-note" as="p" class="validity-note" />
|
||||
</section>
|
||||
|
||||
<section class="faq-wrap">
|
||||
<h1>Tutto quello che vuoi sapere prima di acquistare.</h1>
|
||||
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><p>Come funziona il pacchetto a incontri per One to One e Small Group?</p><div class="faq-toggle">+</div></div>
|
||||
<p class="faq-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 (351 753 5977)</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.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><p>Come funziona il percorso Reformer o Holistic Class?</p><div class="faq-toggle">+</div></div>
|
||||
<p class="faq-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 (351 753 5977)</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><p>Posso usare gli incontri su servizi diversi?</p><div class="faq-toggle">+</div></div>
|
||||
<p class="faq-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.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><p>Posso regalare un pacchetto a qualcun altro?</p><div class="faq-toggle">+</div></div>
|
||||
<p class="faq-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 (351 753 5977)</span> per organizzare le sessioni.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><p>Il pagamento è sicuro?</p><div class="faq-toggle">+</div></div>
|
||||
<p class="faq-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.</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><p>Posso chiedere un rimborso?</p><div class="faq-toggle">+</div></div>
|
||||
<p class="faq-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.</p>
|
||||
</div>
|
||||
<T tag="promo.faq.title" as="h1" />
|
||||
{faqs.map((n) => {
|
||||
const a = resolveContent(`promo.faq.${n}.a`);
|
||||
return (
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
|
||||
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div class="contact-bar">
|
||||
<p>Via Leandro Alberti 76 e Via De Mattiolo 5, 40139 Bologna · 351 753 5977 · info@insanitylab.it</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -225,10 +165,6 @@ export const prerender = false;
|
||||
|
||||
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
|
||||
|
||||
/* CONTACT BAR */
|
||||
.lp .contact-bar { padding: 20px 64px 40px; text-align: center; }
|
||||
.lp .contact-bar p { margin: 0; font-size: 11.5px; line-height: 1.6; color: #8A8980; }
|
||||
|
||||
/* FAQ */
|
||||
.lp .faq-wrap { padding: 72px 64px; }
|
||||
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
|
||||
@@ -248,7 +184,7 @@ export const prerender = false;
|
||||
.lp .faq-a .hl { color: #8A6E3F; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap, .lp .contact-bar { padding-left: 28px; padding-right: 28px; }
|
||||
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
|
||||
.lp .cards { grid-template-columns: 1fr; }
|
||||
.lp .hero h1 { font-size: 32px; }
|
||||
.lp .launch h1 { font-size: 28px; }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getDb } from '../lib/db';
|
||||
import { services } from '../data/services';
|
||||
export const prerender = false;
|
||||
|
||||
const STATIC = ['/', '/about', '/contact', '/training', '/services', '/blog', '/promo-eventi'];
|
||||
const STATIC = ['/', '/about', '/contact', '/training', '/services', '/blog', '/promo-eventi', '/privacy', '/cookie-policy'];
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, '');
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
import TrainingCards from '../components/home/TrainingCards.astro';
|
||||
import Agenda from '../components/Agenda.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import { trainings } from '../data/trainings';
|
||||
import trialImg from '../assets/img/trial-class.jpg';
|
||||
import T from '../components/content/T.astro';
|
||||
import TImg from '../components/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;
|
||||
@@ -29,7 +26,7 @@ export const prerender = false;
|
||||
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
|
||||
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
|
||||
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
|
||||
<h2 data-tag={`training.${tr.id}.title`}>{t(`training.${tr.id}.title`).toLowerCase()}</h2>
|
||||
<h2 data-tag={`training.${tr.id}.title`}>{t(`training.${tr.id}.title`)}</h2>
|
||||
<T tag={`training.${tr.id}.description`} as="p" />
|
||||
<a class="btn" href={tr.cta.href}><T tag={`training.${tr.id}.cta-label`} as="span" /></a>
|
||||
</div>
|
||||
@@ -37,27 +34,11 @@ export const prerender = false;
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Agenda />
|
||||
|
||||
<section class="section section--alt">
|
||||
<div class="container trial">
|
||||
<TImg tag="training.trial.image" src={trialImg} alt="Lezione di prova InsanityLab" widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal="left" />
|
||||
<div data-reveal="right">
|
||||
<T tag="training.trial.title" as="h2" />
|
||||
<T tag="training.trial.body" as="p" />
|
||||
<a class="btn btn--dark" href="/contact">{t('training.trial.cta')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
|
||||
.tblock--reverse img { order: 2; }
|
||||
.tblock h2 { letter-spacing: .1em; }
|
||||
.trial { display: grid; grid-template-columns: 1fr 1.2fr; gap: 60px; align-items: center; }
|
||||
.trial img { width: 100%; height: auto; max-height: 460px; object-fit: cover; }
|
||||
@media (max-width: 767px) { .trial { grid-template-columns: 1fr; gap: 24px; } }
|
||||
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -73,6 +73,8 @@ html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1
|
||||
.cs-size-s { font-size: .85em; }
|
||||
.cs-size-m { font-size: 1em; }
|
||||
.cs-size-l { font-size: 1.25em; }
|
||||
.cs-size-xl { font-size: 1.5em; }
|
||||
.cs-size-xxl { font-size: 2em; }
|
||||
.cs-color-accent { color: var(--c-accent); }
|
||||
.cs-color-accent-dark { color: var(--c-accent-dark); }
|
||||
.cs-color-dark { color: var(--c-dark); }
|
||||
@@ -87,3 +89,4 @@ html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1
|
||||
.cs-align-left { text-align: left; }
|
||||
.cs-align-center { text-align: center; }
|
||||
.cs-align-right { text-align: right; }
|
||||
.cs-align-justify { text-align: justify; }
|
||||
|
||||