37 Commits

Author SHA1 Message Date
Adriano 3b8983dca5 fix(img): foto Reformer Class in alta risoluzione
Sostituita la foto piccola (770px) con la versione HD della stessa scena
reformer (1600x1066) dagli asset Figma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:41:51 +02:00
Adriano 3b4d38e087 fix(img): foto corretta per Holistic Class
La foto di Holistic Class mostrava macchinari Reformer (era la stessa immagine
di Reformer Class). Sostituita con una foto Pilates/Yoga a corpo libero (due
persone di età diverse, coerente con 'adatta a tutti i livelli'), dagli asset Figma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:32:19 +02:00
Adriano 02b5180227 fix(metodo): allinea i testi degli step del Metodo ai valori voluti
Gli step del blocco Metodo (home) erano stati riscritti dal pannello ma solo nel
DB di sviluppo: il database non è versionato, quindi non erano mai arrivati in
produzione (che mostrava ancora i default del seed). Aggiornato il seed method.ts
(Check-up fisico, Insanity plan, Monitoraggio costante + testi) e aggiunta
migrazione idempotente che allinea le righe di prod ancora ai vecchi default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:32:44 +02:00
Adriano c722680590 chore(deploy): vecchio insanitylab.tielogic.xyz → 301 all'apex
Conclusa la migrazione al dominio ufficiale: il sottodominio di transizione
non serve più il sito ma redirige permanentemente a insanitylab.it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:25:45 +02:00
Adriano 172cef236b feat(gdpr): conformità privacy — mappa a consenso, pagine legali, titolare
- Mappa Google su /contact ripristinata a click-to-load (consenso esplicito):
  nessun dato inviato a Google prima del click dell'utente
- Nuove pagine /privacy e /cookie-policy (informativa art. 13 GDPR e cookie),
  compilate con i dati del titolare IN-SANITY LAB S.R.L. (P.IVA 03927341200)
- Footer: ragione sociale + P.IVA e link a Privacy e Cookie Policy
- Form contatti: link all'informativa privacy sotto l'invio
- sitemap: aggiunte /privacy e /cookie-policy

Testi legali da far validare; base tecnica conforme (no analytics/tracker,
solo cookie tecnici, font self-hosted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:19:57 +02:00
Adriano 1aa82f5332 docs: README unico e aggiornato; rimozione piani/spec superati
- README riscritto come documentazione viva e completa: stack, funzionalita'
  (contenuti taggati + GUID + modifica inline, 3 ruoli admin/superuser/user,
  blog ownership, gestione utenti), env SMTP Aruba, deploy Docker/Traefik,
  dominio insanitylab.it, migrazioni DB e trappole tecniche note
- rimossi docs/superpowers/plans e docs/superpowers/specs: piani gia' eseguiti
  e spec di design ora accorpati nel README (restano nella storia git)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:50:21 +02:00
Adriano d79aa912b1 feat(deploy): dominio ufficiale insanitylab.it (apex canonico)
- astro.config: site=https://insanitylab.it, allowedDomains apex+www+tielogic
- compose/Traefik: router serve insanitylab.it e (in transizione) tielogic.xyz;
  www.insanitylab.it redirige 301 all'apex. TLS Let's Encrypt via certresolver.
  tielogic resta servito per non avere downtime finché il DNS non propaga;
  il suo redirect all'apex verra' attivato dopo la conferma che il dominio e' live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:20:38 +02:00
Adriano 86d608a929 fix(hero): titolo su 3 righe (PERFORMANCE./BALANCE./LONGEVITY.)
Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
di HeroSlider, quindi le regole scoped .hero__line (display:block e animazioni)
non lo colpivano e le righe restavano inline. Ancorato a .hero__text/h1 (che il
cid ce l'hanno) con :global(.hero__line), come già fatto per .prog__desc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:54:50 +02:00
Adriano 708410bbf1 feat(home): rimuove le sezioni Agenda e Team dalla pagina principale
Tolte da index.astro le sezioni <Agenda> e <TeamSection> con i relativi import.
I componenti restano nel codice (riutilizzabili altrove). Modifica isolata su
branch dedicato per valutazione col cliente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:47 +02:00
Adriano 53af0ca00e feat(home): nuova immagine per lo step "Insanity plan" del Metodo
Grafica di brand (barre di progressione, curva, checklist protocolli) al posto
della vecchia foto, coerente col testo aggiornato dello step 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:40:57 +02:00
Adriano 6594f6da53 docs(env): configurazione SMTP Aruba per il form contatti
smtps.aruba.it:465 con casella autenticata del dominio (il mittente deve
coincidere con la casella SMTP). Il dominio invia già la posta via Aruba
(SPF include:aruba.it). Credenziali reali solo nel .env (git-ignored) e sul VPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:41:20 +02:00
Adriano c6de02be83 Contatti, /training e immagini: rifiniture post-merge
- Rimossa la sezione "Prenota la lezione di prova" da /training (markup,
  import, CSS, seed e blocchi DB relativi)
- Titoli dei riquadri Training non più forzati in minuscolo: rispettano il
  testo del DB (One to One, Small Groups, ecc.)
- Footer e pagina Contatti con i due ingressi dello stesso stabile
  (Via Leandro Alberti 76 e Via De Mattiolo 5) più l'email; nuovo tag
  global.contact.map-query e migrazione DB idempotente dell'indirizzo
- Mappa Google in /contact caricata automaticamente e a piena dimensione
  (rimosso il bottone e il relativo script)
- Landing Promo ed eventi: rimossa la barra contatti in fondo
- service-icons allineate agli slug attuali (one-to-one, small-group;
  rimosse le chiavi obsolete personal-training e pilates-flow)
- Immagini: foto Rehab corretta (prima era di osteopatia) su program-rehab
  e training-rehab; bande nere letterbox rimosse dalla foto di osteopatia;
  nuova immagine grafica per Profilazione con grafici HRV (ECG, tacogramma
  R-R, Poincaré plot)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:41:37 +02:00
Adriano 11fc2b3e54 Servizi e landing Promo ed eventi: rinomine, pulizia contenuti e nuova pagina
Servizi:
- Personal Training rinominato "One to One" (slug /services/one-to-one) con
  redirect 301 dal vecchio URL e migrazione DB idempotente dei contenuti taggati
- Nuova pagina "Small Group" (slug /services/small-group, fuori dalla griglia)
- Rimossi i titoli intro di /training e /services e le liste feature di One to
  One, Small Group, Rehab e Osteopatia; la lista feature ora è condizionale
- Fix elenco puntato nel long-description (white-space: pre-line, selettore
  ancorato al container per gli elementi resi dal componente <T>)
- Immagine di Performance Class aggiornata
- Nella griglia Servizi i riquadri "One to One" e "Performance Class" rimandano
  a /training; nella pagina Training i riquadri One to One e Small Group aprono
  le rispettive schede servizio

Landing:
- Pagina "Promo ed eventi" (slug /promo-eventi) integrata nel sito con header e
  footer condivisi, basata sul nuovo mockup di lancio
- Card a 5 incontri con prezzi edizione apertura, FAQ ad accordion e acquisto
  tramite gestionale bsport
- Nuova voce di menu "Promo ed eventi"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:33:11 +02:00
Adriano 5b34ee1697 Home: bottone Contattaci in fondo alla sezione Metodo
Come da documento cliente. Nuovo tag home.method.cta (auto-seed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 17:34:57 +02:00
Adriano 2468ce95f7 About: testi aggiornati e rimozione sezioni Metodo, Vision, StoryLab
- Testi riscritti da documento cliente: about.about.body, convenzioni.item1/item2
  (rimosso "gratuita"), studios.body1 (versione breve). Seed + migrazione DB
  idempotente (guardia via frammento LIKE, rispetta le modifiche da pannello).
- Rimosse dalla pagina le sezioni marcate "LEVARE" nel documento: Timeline/StoryLab,
  Metodo (presente in home) e Vision. I tag restano nel seed per un eventuale ripristino.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 16:43:50 +02:00
Adriano 9966ec3616 Training: 6 riquadri (Rehab, Holistic Class, Reformer Class, Coaching)
- trainings.ts: da 4 a 6 riquadri (One to One, Small Group max 4, Rehab,
  Holistic Class ex Performance Class, Reformer Class nuova, Coaching ex Fit
  Remote); anche in Homepage. CTA "Scopri" verso le pagine dettaglio.
- services.ts: aggiunte pagine dettaglio rehab/holistic-class/reformer-class con
  flag grid:false (fuori dalla griglia /services ma raggiungibili come
  approfondimento dai riquadri Training). Rehab torna consultabile su /services/rehab.
- index/[slug] servizi filtrano grid!==false; rimosso redirect /services/rehab.
- Footer Training portato a 6 voci con ancore aggiornate; TrainingCards tone-safe.
- Foto Reformer dal documento cliente; card Rehab/Holistic/Coaching con
  immagini placeholder (da confermare). Migrazione DB idempotente per label/meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 13:26:58 +02:00
Adriano 140146fce1 Servizi: Pilates Flow -> Profilazione, Rehab -> Osteopatia
- services.ts: nuovi servizi profilazione (copy bozza dal metodo ISL) e
  osteopatia (copy dal documento cliente); rehab esce da Servizi (va in Training).
- Redirect 301 dai vecchi slug: pilates-flow -> profilazione, rehab -> training.
- Footer, icone, sitemap e meta aggiornati; migrazione DB idempotente per le
  label footer e la meta description (contenuti live in SQLite).
- Immagine osteopatia riusa la foto rehab (come da mockup cliente).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 13:10:37 +02:00
Adriano 7211d74d3a fix(sicurezza): safeNext blocca backslash (open redirect) + guardia ownership su /admin/edit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:34:16 +02:00
Adriano e2bbc9074f fix(create-user): ruoli aggiornati a superuser/user (gap dal piano) 2026-07-05 22:23:32 +02:00
Adriano be77285613 test(smoke): login pubblico, badge, protezione /admin/users 2026-07-05 22:21:30 +02:00
Adriano c36ad4cb2d feat(users): gestione utenti /admin/users con guardie self/ultimo-admin 2026-07-05 22:18:20 +02:00
Adriano 0f2b2ba014 feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab 2026-07-05 22:15:30 +02:00
Adriano 2bf6b3d4de feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore 2026-07-05 22:13:15 +02:00
Adriano 22d72be74d feat(inline): badge con guid + modal di modifica contenuti in pagina 2026-07-05 22:10:50 +02:00
Adriano 842371604b feat(showtags): endpoint cookie + toggle nel menu utente 2026-07-05 22:05:46 +02:00
Adriano 71cd1626e0 feat(header): identità utente SSR, link Accedi / menu utente 2026-07-05 22:03:07 +02:00
Adriano 6b85872515 feat(login): pagina pubblica /login con next sicuro 2026-07-05 21:59:50 +02:00
Adriano 5ce10da9bd feat(content): guid in resolve/componenti + GET /api/admin/content/[tag] 2026-07-05 21:56:53 +02:00
Adriano c3267c9be2 feat(posts-api): assegna autore in POST, ownership su PUT/DELETE 2026-07-05 21:51:34 +02:00
Adriano 6d1e2607ee feat(posts): author_id nel repository + listByAuthor + helper utenti 2026-07-05 21:49:16 +02:00
Adriano db92fc9c02 feat(middleware): autorizzazione a matrice + redirect per ruolo 2026-07-05 21:47:24 +02:00
Adriano e87a464090 feat(auth): matrice permessi 3 ruoli + landingFor 2026-07-05 21:44:30 +02:00
Adriano 5cea895d51 feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id 2026-07-05 21:40:28 +02:00
Adriano d4cf359805 docs: rifinisci test migrazione nel piano 2026-07-05 21:38:47 +02:00
Adriano d0fa75a3d3 docs: piano implementazione ruoli/login/GUID/modifica inline 2026-07-05 21:37:53 +02:00
Adriano 6c225281e0 docs: spec ruoli/login/GUID/modifica inline 2026-07-05 21:14:30 +02:00
Adriano 8ceea9b16f feat: rinomina sezione Programmi in Servizi (route, tag, migrazione DB)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:23:48 +02:00
85 changed files with 1915 additions and 5558 deletions
+10 -5
View File
@@ -1,8 +1,13 @@
SMTP_HOST=smtp.example.com # SMTP Aruba — il dominio insanitylab.it invia la posta via Aruba (SPF: include:aruba.it)
SMTP_PORT=587 # Server posta in uscita autenticato: smtps.aruba.it:465 (SSL). Verificato funzionante.
SMTP_USER=user SMTP_HOST=smtps.aruba.it
SMTP_PASS=pass 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_TO=info@insanitylab.it
CONTACT_FROM=sito@insanitylab.it
DB_PATH=data/insanitylab.db DB_PATH=data/insanitylab.db
UPLOADS_DIR=uploads UPLOADS_DIR=uploads
+103 -94
View File
@@ -1,145 +1,154 @@
# InsanityLab Website # 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 ## 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 richtext, e il form di contatti invia email tramite SMTP.
## Stack Tecnologico ## Stack tecnologico
- **Framework**: Astro 7 - **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
- **Runtime**: Node.js (adapter standalone) - **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone)
- **Database**: SQLite (better-sqlite3) - **Database**: SQLite via `better-sqlite3`
- **Autenticazione**: bcryptjs - **Autenticazione**: sessioni con password hashate (`bcryptjs`)
- **Email**: Nodemailer - **Email**: Nodemailer (SMTP)
- **Markup Editor**: TipTap (rich text) - **Editor articoli**: TipTap (rich text)
- **Font System**: @fontsource (Montserrat, Open Sans) - **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati) - **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
- **Testing**: Vitest - **Test**: Vitest
- **Linguaggio**: TypeScript - **Linguaggio**: TypeScript
- **Deploy**: Docker (multistage, `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 antispam, ratelimit e invio email via SMTP.
## Installazione (sviluppo)
### Prerequisiti ### Prerequisiti
- Node.js 18+ e npm - Node.js 18+ e npm
### Setup ### Setup
```bash ```bash
# Installa dipendenze
npm install npm install
cp .env.example .env # poi compila i valori reali
# Copia il template di configurazione env npm run create-user -- admin <password> admin
cp .env.example .env npm run dev # http://localhost:4321
# Crea il primo utente amministratore (dopo aver configurato il DB)
npm run create-user -- admin tuapassword
``` ```
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 | | Comando | Descrizione |
|---------|-------------| |---------|-------------|
| `npm run dev` | Avvia il server di sviluppo (localhost:4321) | | `npm run dev` | Server di sviluppo (localhost:4321) |
| `npm run build` | Compila il progetto per production | | `npm run build` | Build di produzione |
| `npm run preview` | Visualizza l'anteprima della build (locale) | | `npm run preview` | Anteprima locale della build |
| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) | | `npm run start` | Server di produzione (`node --env-file=.env`) |
| `npm run test` | Esegui i test con Vitest | | `npm run test` | Test con Vitest |
| `npm run create-user` | Crea un nuovo utente: `-- <nome> <password> [admin\|editor]` | | `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 unatantum, cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione e la rimozione dell'ultimo admin.
## Struttura del progetto
``` ```
insanitylab-website/ insanitylab-website/
├── src/ ├── src/
│ ├── pages/ # Pagine Astro (routing automatico) │ ├── pages/ # Route Astro (vetrina, /admin, /api)
│ ├── components/ # Componenti Astro riutilizzabili │ ├── components/ # Componenti (home/, content/, Header, Footer, …)
│ ├── layouts/ # Layout master │ ├── layouts/ # Layout Base
│ ├── utils/ # Funzioni di utilità │ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
│ ├── env.d.ts # Type definitions globali │ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
── ... ── assets/img/ # Immagini ottimizzate da Astro
├── data/ # Database SQLite e dati persistenti │ └── styles/ # global.css
│ └── insanitylab.db # (generato al primo avvio) ├── data/ # SQLite (volume in produzione)
├── uploads/ # Directory per upload immagini e file ├── uploads/ # Upload immagini (volume in produzione)
├── dist/ # Output build (server) ├── scripts/ # create-user, smoke test
├── tests/ # Test suite ├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
├── astro.config.mjs # Configurazione Astro ├── compose.yaml # Stack Docker + label Traefik
├── tsconfig.json # Configurazione TypeScript ├── Dockerfile # Build multi-stage
├── vitest.config.ts # Configurazione test framework ├── .env.example # Template variabili d'ambiente
── .env.example # Template variabili di ambiente ── README.md
├── .env # Variabili di ambiente (non versionato)
├── package.json # Dipendenze e script npm
└── README.md # Questo file
``` ```
## 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 | Variabile | Descrizione |
- `SMTP_PORT`: Porta SMTP (solitamente 587) |-----------|-------------|
- `SMTP_USER`: Utente SMTP | `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) |
- `SMTP_PASS`: Password SMTP | `SMTP_PORT` | Porta SMTP (`465` con SSL) |
- `CONTACT_TO`: Email di destinazione contatti | `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) |
- `CONTACT_FROM`: Email mittente | `SMTP_PASS` | Password della casella |
- `DB_PATH`: Percorso database SQLite | `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) |
- `UPLOADS_DIR`: Cartella upload file | `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` ## Deploy in produzione
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)
## 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 ```bash
# Build per production cd /opt/docker/insanitylab/src
npm run build git pull
docker compose up -d --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.)
``` ```
## 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: ### Dominio e DNS
- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md`
## 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: ## Problemi comuni
- **Linux**: `sudo apt-get install build-essential python3`
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: Visual Studio Build Tools o MinGW
### 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 ## Licenza
Proprietario InsanityLab. Proprietà di InsanityLab.
+18 -2
View File
@@ -2,12 +2,28 @@ import { defineConfig } from 'astro/config';
import node from '@astrojs/node'; import node from '@astrojs/node';
export default defineConfig({ export default defineConfig({
site: 'https://insanitylab.tielogic.xyz', site: 'https://insanitylab.it',
output: 'server', output: 'server',
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora // Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403). // X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
// sottodominio tielogic.xyz redirigono all'apex a livello Traefik.
security: { 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' }), adapter: node({ mode: 'standalone' }),
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
redirects: {
'/programs': { status: 301, destination: '/services' },
'/programs/[slug]': { status: 301, destination: '/services/[slug]' },
// Modifiche 02: "Pilates Flow" → "Profilazione". "Rehab" esce dalla griglia Servizi e
// diventa un riquadro Training, ma la pagina dettaglio resta su /services/rehab.
'/services/pilates-flow': { status: 301, destination: '/services/profilazione' },
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
},
}); });
+12 -1
View File
@@ -16,11 +16,22 @@ services:
- /opt/docker/insanitylab/uploads:/app/uploads - /opt/docker/insanitylab/uploads:/app/uploads
labels: labels:
- traefik.enable=true - 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.tls=true
- traefik.http.routers.insanitylab.entrypoints=websecure - traefik.http.routers.insanitylab.entrypoints=websecure
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
- traefik.http.services.insanitylab.loadbalancer.server.port=4321 - traefik.http.services.insanitylab.loadbalancer.server.port=4321
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
- 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: networks:
- traefik - traefik
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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.&nbsp; BALANCE.&nbsp; 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 (`&nbsp;`), 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.
+2 -2
View File
@@ -8,8 +8,8 @@ if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]'); console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1); process.exit(1);
} }
if (!['admin', 'editor'].includes(role)) { if (!['admin', 'superuser', 'user'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, editor'); console.error('Ruolo non valido. Ammessi: admin, superuser, user');
process.exit(1); process.exit(1);
} }
const path = process.env.DB_PATH ?? 'data/insanitylab.db'; const path = process.env.DB_PATH ?? 'data/insanitylab.db';
+21 -1
View File
@@ -6,7 +6,7 @@ PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
trap 'kill $SRV' EXIT trap 'kill $SRV' EXIT
sleep 2 sleep 2
fail=0 fail=0
for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do for path in / /about /training /services /services/performance-class /services/rehab /blog /contact /admin/login /robots.txt; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path") code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
echo "$code $path" echo "$code $path"
[ "$code" = "200" ] || fail=1 [ "$code" = "200" ] || fail=1
@@ -28,4 +28,24 @@ echo "$tags righe con data-tag= in home (atteso >= 1)"
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true) badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)" echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
[ "$badges" = "0" ] || fail=1 [ "$badges" = "0" ] || fail=1
# /login pubblico raggiungibile
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
echo "$code /login"
[ "$code" = "200" ] || fail=1
# Header mostra "Accedi" da anonimo
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
# Nessun badge tag-badge da anonimo sulla home semplice
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
[ "$badges2" = "0" ] || fail=1
# Rotta gestione utenti protetta (redirect al login da anonimo)
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
echo "$code /admin/users (atteso 302)"
[ "$code" = "302" ] || fail=1
echo "smoke ruoli/login/inline OK"
exit $fail exit $fail
Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+6
View File
@@ -20,9 +20,15 @@ const { variant = 'full' } = Astro.props;
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea> <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" /> <input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button> <button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
<p class="form-msg" hidden></p> <p class="form-msg" hidden></p>
</form> </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> <script>
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => { document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
+8 -5
View File
@@ -11,17 +11,18 @@ const year = new Date().getFullYear();
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" /> <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.tagline" as="p" class="ftr__tag" />
<T tag="footer.about" as="p" /> <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.1')}</p>
<p class="ftr__hours">{t('global.hours.2')}</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> </div>
<nav aria-label="training"> <nav aria-label="training">
<T tag="footer.col.training-title" as="h4" /> <T tag="footer.col.training-title" as="h4" />
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)} {site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
</nav> </nav>
<nav aria-label="programmi"> <nav aria-label="servizi">
<T tag="footer.col.programs-title" as="h4" /> <T tag="footer.col.services-title" as="h4" />
{site.footerPrograms.map((l, i) => <a href={l.href}>{t(`footer.programs.${i + 1}.label`)}</a>)} {site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav> </nav>
<div> <div>
<T tag="footer.col.contact-title" as="h4" /> <T tag="footer.col.contact-title" as="h4" />
@@ -30,7 +31,7 @@ const year = new Date().getFullYear();
</div> </div>
<div class="ftr__bar"> <div class="ftr__bar">
<div class="container ftr__bar-in"> <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> <p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div> </div>
</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__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__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
.ftr__hours { margin: 0; } .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 { 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 { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; } .ftr__bar-in a { display: inline; margin-left: 12px; }
+47
View File
@@ -1,7 +1,15 @@
--- ---
import { site } from '../data/site'; import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content'; import { t, contentImageUrl } from '../lib/content';
interface Props {
user: { username: string; role: string } | null;
showTags: boolean;
canTags: boolean;
}
const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname; const path = Astro.url.pathname;
const loginHref = `/login?next=${encodeURIComponent(path)}`;
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
--- ---
<header class="hdr" id="site-header"> <header class="hdr" id="site-header">
<div class="container hdr__in"> <div class="container hdr__in">
@@ -11,6 +19,22 @@ const path = Astro.url.pathname;
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a> <a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
))} ))}
</nav> </nav>
<div class="hdr__user">
{user ? (
<details class="umenu">
<summary class="umenu__btn">{user.username} ▾</summary>
<div class="umenu__panel">
<a href={areaHref}>Area riservata</a>
{canTags && (
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
)}
<a href="/admin/logout">Esci</a>
</div>
</details>
) : (
<a class="hdr__login" href={loginHref}>Accedi</a>
)}
</div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false"> <button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</button> </button>
@@ -32,6 +56,15 @@ const path = Astro.url.pathname;
.hdr__link { padding: 12px 0; } .hdr__link { padding: 12px 0; }
.hdr__burger { display: block; } .hdr__burger { display: block; }
} }
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__login:hover { color: var(--c-accent-dark); }
.umenu { position: relative; }
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
.umenu__btn::-webkit-details-marker { display: none; }
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
</style> </style>
<script> <script>
@@ -43,4 +76,18 @@ const path = Astro.url.pathname;
const open = nav.classList.toggle('is-open'); const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open)); toggle.setAttribute('aria-expanded', String(open));
}); });
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
</script> </script>
+2 -2
View File
@@ -7,5 +7,5 @@ const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined; const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
--- ---
{c.type === 'html' {c.type === 'html'
? <Tag data-tag={tag} class={classes} set:html={c.value} /> ? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
: <Tag data-tag={tag} class={classes}>{c.value}</Tag>} : <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
+6 -4
View File
@@ -1,14 +1,16 @@
--- ---
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { getImageOverride } from '../../lib/content'; import { resolveContent } from '../../lib/content';
interface Props { interface Props {
tag: string; src: ImageMetadata; alt: string; tag: string; src: ImageMetadata; alt: string;
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number; class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
} }
const { tag, src, alt, class: cls, ...rest } = Astro.props; const { tag, src, alt, class: cls, ...rest } = Astro.props;
const override = getImageOverride(tag); const c = resolveContent(tag);
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
const guid = c.guid || undefined;
--- ---
{override {override
? <img src={override} alt={alt} class={cls} data-tag={tag} loading={rest.loading} /> ? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
: <Image src={src} alt={alt} class={cls} data-tag={tag} {...rest} />} : <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
+1 -1
View File
@@ -19,7 +19,7 @@ const blocks = [
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}> <div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
<T tag={`home.feature.${b.key}.title`} as="h2" /> <T tag={`home.feature.${b.key}.title`} as="h2" />
<T tag={`home.feature.${b.key}.body`} as="p" /> <T tag={`home.feature.${b.key}.body`} as="p" />
<a class="btn" href="/programs">{t(`home.feature.${b.key}.cta`)}</a> <a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</a>
</div> </div>
</div> </div>
))} ))}
+9 -7
View File
@@ -13,7 +13,7 @@ const slides = [
<div class="container hero__in"> <div class="container hero__in">
<div class="hero__text"> <div class="hero__text">
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1> <h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/programs">{t('home.hero.cta')}</a> <a class="btn" href="/services">{t('home.hero.cta')}</a>
</div> </div>
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" /> <TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
</div> </div>
@@ -35,7 +35,9 @@ const slides = [
/* Come nel Figma: figure a piena altezza ancorate in basso a destra, /* Come nel Figma: figure a piena altezza ancorate in basso a destra,
testo sovrapposto sulla sinistra. */ testo sovrapposto sulla sinistra. */
.hero__in { position: relative; display: flex; align-items: center; min-height: calc(100vh - var(--header-h)); } .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 { position: relative; z-index: 1; max-width: 46%; }
.hero__text h1 { font-weight: 500; } .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; } .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 */ /* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } } @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; } } @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__text h1 :global(.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__text h1 :global(.hero__line:nth-child(1)) { animation-delay: .15s; }
:global(html.js) .hero__line:nth-child(2) { animation-delay: .3s; } :global(html.js) .hero__text h1 :global(.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: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__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; } :global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
@media (prefers-reduced-motion: reduce) { @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> </style>
+1 -1
View File
@@ -15,7 +15,7 @@ const cols = await Promise.all([
--- ---
<section class="info" aria-label="pilastri"> <section class="info" aria-label="pilastri">
{cols.map((c, i) => ( {cols.map((c, i) => (
<a class="info__col" href="/programs" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}> <a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
<T tag={c.titleTag} as="span" class="info__title" /> <T tag={c.titleTag} as="span" class="info__title" />
</a> </a>
))} ))}
+4
View File
@@ -25,11 +25,15 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
</div> </div>
))} ))}
</div> </div>
<div class="method__cta" data-reveal>
<a class="btn btn--dark" href="/contact"><T tag="home.method.cta" as="span" /></a>
</div>
</div> </div>
</section> </section>
<style> <style>
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; } .method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
.method__cta { text-align: center; margin-top: 48px; }
.method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; } .method__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 { 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; } .method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
@@ -1,20 +1,20 @@
--- ---
import { programs } from '../../data/programs'; import { services } from '../../data/services';
import { programIcons as icons } from '../../data/program-icons'; import { serviceIcons as icons } from '../../data/service-icons';
import T from '../content/T.astro'; import T from '../content/T.astro';
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading" data-reveal> <div class="section-heading" data-reveal>
<T tag="home.programs.title" as="h2" /> <T tag="home.services.title" as="h2" />
<T tag="home.programs.body" as="p" /> <T tag="home.services.body" as="p" />
</div> </div>
<div class="pgrid"> <div class="pgrid">
{programs.map((p, i) => ( {services.map((p, i) => (
<a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}> <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> <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={`program.${p.slug}.title`} as="h3" /> <T tag={`service.${p.slug}.title`} as="h3" />
<T tag={`program.${p.slug}.subtitle`} as="p" /> <T tag={`service.${p.slug}.subtitle`} as="p" />
</a> </a>
))} ))}
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
{trainings.map((tr, i) => { {trainings.map((tr, i) => {
const img = imgOf(tr.image); const img = imgOf(tr.image);
const card = ( const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}> <div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
<T tag={`training.${tr.id}.title`} as="h3" /> <T tag={`training.${tr.id}.title`} as="h3" />
<T tag={`training.${tr.id}.caption`} as="p" /> <T tag={`training.${tr.id}.caption`} as="p" />
<a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a> <a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
+49 -50
View File
@@ -5,7 +5,7 @@ import { team } from './team';
import { methodIntro, methodExtraNote, methodSteps } from './method'; import { methodIntro, methodExtraNote, methodSteps } from './method';
import { partners } from './partners'; import { partners } from './partners';
import { disciplines, days, slots } from './agenda'; import { disciplines, days, slots } from './agenda';
import { programs } from './programs'; import { services } from './services';
import { performanceClassPricing } from './pricing'; import { performanceClassPricing } from './pricing';
export type ContentType = 'text' | 'html' | 'image'; export type ContentType = 'text' | 'html' | 'image';
@@ -23,6 +23,7 @@ export const contentSeed: SeedEntry[] = [
T('global.contact.email', site.email, []), T('global.contact.email', site.email, []),
T('global.contact.address', site.address, []), T('global.contact.address', site.address, []),
T('global.contact.city-line', site.cityLine, []), T('global.contact.city-line', site.cityLine, []),
T('global.contact.map-query', site.mapQuery, []),
T('global.hours.1', site.hours[0], []), T('global.hours.1', site.hours[0], []),
T('global.hours.2', site.hours[1], []), T('global.hours.2', site.hours[1], []),
T('global.nav.1.label', site.navigation[0].label, []), T('global.nav.1.label', site.navigation[0].label, []),
@@ -30,6 +31,7 @@ export const contentSeed: SeedEntry[] = [
T('global.nav.3.label', site.navigation[2].label, []), T('global.nav.3.label', site.navigation[2].label, []),
T('global.nav.4.label', site.navigation[3].label, []), T('global.nav.4.label', site.navigation[3].label, []),
T('global.nav.5.label', site.navigation[4].label, []), T('global.nav.5.label', site.navigation[4].label, []),
T('global.nav.6.label', site.navigation[5].label, []),
// --- Header --- // --- Header ---
T('header.logo.alt', 'InsanityLab', []), T('header.logo.alt', 'InsanityLab', []),
@@ -40,7 +42,7 @@ export const contentSeed: SeedEntry[] = [
H('footer.tagline', 'PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.'), H('footer.tagline', 'PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.'),
T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'), T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'),
T('footer.col.training-title', 'Training'), T('footer.col.training-title', 'Training'),
T('footer.col.programs-title', 'Programmi'), T('footer.col.services-title', 'Servizi'),
T('footer.col.contact-title', 'Contact'), T('footer.col.contact-title', 'Contact'),
H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'), H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'),
T('footer.social-label', 'Seguici', []), T('footer.social-label', 'Seguici', []),
@@ -48,12 +50,14 @@ export const contentSeed: SeedEntry[] = [
T('footer.training.2.label', site.footerTraining[1].label, []), T('footer.training.2.label', site.footerTraining[1].label, []),
T('footer.training.3.label', site.footerTraining[2].label, []), T('footer.training.3.label', site.footerTraining[2].label, []),
T('footer.training.4.label', site.footerTraining[3].label, []), T('footer.training.4.label', site.footerTraining[3].label, []),
T('footer.programs.1.label', site.footerPrograms[0].label, []), T('footer.training.5.label', site.footerTraining[4].label, []),
T('footer.programs.2.label', site.footerPrograms[1].label, []), T('footer.training.6.label', site.footerTraining[5].label, []),
T('footer.programs.3.label', site.footerPrograms[2].label, []), T('footer.services.1.label', site.footerServices[0].label, []),
T('footer.programs.4.label', site.footerPrograms[3].label, []), T('footer.services.2.label', site.footerServices[1].label, []),
T('footer.programs.5.label', site.footerPrograms[4].label, []), T('footer.services.3.label', site.footerServices[2].label, []),
T('footer.programs.6.label', site.footerPrograms[5].label, []), T('footer.services.4.label', site.footerServices[3].label, []),
T('footer.services.5.label', site.footerServices[4].label, []),
T('footer.services.6.label', site.footerServices[5].label, []),
// --- Contact form --- // --- Contact form ---
T('form.field.first-name', 'Nome', []), T('form.field.first-name', 'Nome', []),
@@ -115,9 +119,9 @@ export const contentSeed: SeedEntry[] = [
I(`training.${tr.id}.image`), I(`training.${tr.id}.image`),
]), ]),
// --- Home: programmi --- // --- Home: servizi ---
T('home.programs.title', 'Programmi'), T('home.services.title', 'Servizi'),
T('home.programs.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.'), T('home.services.body', 'Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.'),
// --- Home: team (data-driven da team.ts) --- // --- Home: team (data-driven da team.ts) ---
H('home.team.title', 'Team<br />Trainer'), H('home.team.title', 'Team<br />Trainer'),
@@ -136,6 +140,7 @@ export const contentSeed: SeedEntry[] = [
// --- Home: metodo (data-driven da method.ts) --- // --- Home: metodo (data-driven da method.ts) ---
H('home.method.title', 'Il metodo<br />InsanityLab'), H('home.method.title', 'Il metodo<br />InsanityLab'),
T('home.method.cta', 'Contattaci', []),
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')), H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
T('method.extra-note', methodExtraNote, []), T('method.extra-note', methodExtraNote, []),
...methodSteps.flatMap((s) => [ ...methodSteps.flatMap((s) => [
@@ -176,14 +181,14 @@ export const contentSeed: SeedEntry[] = [
// --- About: about --- // --- About: about ---
T('about.about.title', 'About'), T('about.about.title', 'About'),
T('about.about.body', "InsanityLab nasce con l'obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all'interno di un approccio personalizzato e consapevole."), T('about.about.body', "InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."),
I('about.about.image'), I('about.about.image'),
// --- About: convenzioni --- // --- About: convenzioni ---
T('about.convenzioni.title', 'Convenzioni'), T('about.convenzioni.title', 'Convenzioni'),
T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."), T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."),
T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati, con percorsi individuali, small group training e classi di gruppo seguiti da professionisti qualificati.'), T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'),
T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'), T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'),
T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."), T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."),
T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []), T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
I('about.convenzioni.image'), I('about.convenzioni.image'),
@@ -201,7 +206,7 @@ export const contentSeed: SeedEntry[] = [
// --- About: studios --- // --- About: studios ---
T('about.studios.title', 'In-Sanity Studios'), T('about.studios.title', 'In-Sanity Studios'),
T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse."), T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."),
T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."), T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."),
T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'), T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'),
I('about.studios.image'), I('about.studios.image'),
@@ -223,40 +228,39 @@ export const contentSeed: SeedEntry[] = [
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []), T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
// --- Programs: lista (/programs) --- // --- Services: lista (/services) ---
T('programs.meta.title', 'Programmi', []), T('services.meta.title', 'Servizi', []),
T('programs.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', []), T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []),
T('programs.hero.title', 'Programmi', []), T('services.hero.title', 'Servizi', []),
T('programs.intro.title', 'Programmi'), T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
T('programs.intro.body', "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."), T('services.card.more-label', 'Per saperne di più →', []),
T('programs.card.more-label', 'Per saperne di più →', []),
// --- Programs: dettaglio (data-driven da programs.ts) --- // --- Services: dettaglio (data-driven da services.ts) ---
...programs.flatMap((p) => [ ...services.flatMap((p) => [
T(`program.${p.slug}.title`, p.title), T(`service.${p.slug}.title`, p.title),
T(`program.${p.slug}.subtitle`, p.subtitle), T(`service.${p.slug}.subtitle`, p.subtitle),
T(`program.${p.slug}.excerpt`, p.excerpt), T(`service.${p.slug}.excerpt`, p.excerpt),
T(`program.${p.slug}.long-description`, p.longDescription), T(`service.${p.slug}.long-description`, p.longDescription),
...p.features.map((f, i) => T(`program.${p.slug}.feature.${i + 1}`, f)), ...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
...p.faq.flatMap((item, i) => [ ...p.faq.flatMap((item, i) => [
T(`program.${p.slug}.faq.${i + 1}.q`, item.q), T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
T(`program.${p.slug}.faq.${i + 1}.a`, item.a), T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
]), ]),
I(`program.${p.slug}.image`), I(`service.${p.slug}.image`),
]), ]),
// --- Programs: dettaglio, template --- // --- Services: dettaglio, template ---
T('program.side.categories-label', 'Categorie'), T('service.side.categories-label', 'Categorie'),
T('program.side.category.1', 'Fitness', []), T('service.side.category.1', 'Fitness', []),
T('program.side.category.2', 'Salute', []), T('service.side.category.2', 'Salute', []),
T('program.side.category.3', 'Stile di vita', []), T('service.side.category.3', 'Stile di vita', []),
T('program.side.category.4', 'Nutrizione', []), T('service.side.category.4', 'Nutrizione', []),
T('program.side.others-label', 'Altri programmi'), T('service.side.others-label', 'Altri servizi'),
H('program.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'), H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
T('program.pricing.per', 'al mese, semestrale', []), T('service.pricing.per', 'al mese, semestrale', []),
T('program.pricing.entries-singular', 'ingresso settimanale', []), T('service.pricing.entries-singular', 'ingresso settimanale', []),
T('program.pricing.entries-plural', 'ingressi settimanali', []), T('service.pricing.entries-plural', 'ingressi settimanali', []),
T('program.pricing.buy-cta', 'Acquista', []), T('service.pricing.buy-cta', 'Acquista', []),
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) --- // --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
T('pricing.heading', performanceClassPricing.heading), T('pricing.heading', performanceClassPricing.heading),
@@ -272,14 +276,9 @@ export const contentSeed: SeedEntry[] = [
// --- Training: meta, hero, intro, trial --- // --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []), T('training.meta.title', 'Training', []),
T('training.meta.description', 'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.', []), 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.hero.title', 'training', []),
T('training.intro.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.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 --- // --- Contact ---
T('contact.meta.title', 'Contatti', []), T('contact.meta.title', 'Contatti', []),
+4 -4
View File
@@ -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 methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard.";
export const methodSteps = [ export const methodSteps = [
{ n: 1, title: "Prima consulenza conoscitiva iniziale", text: "Dove raccogliamo unanamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.", extra: false, image: "method-step-1" }, { 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: "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: 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: "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: 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: "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: 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" },
]; ];
-9
View File
@@ -1,9 +0,0 @@
// Path SVG (stroke, viewBox 0 0 24 24) delle icone programmi, condivisi tra griglia home e sidebar
export const programIcons: Record<string, string> = {
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
};
-104
View File
@@ -1,104 +0,0 @@
export interface Program {
slug: string;
title: string;
subtitle: string;
excerpt: string;
longDescription: string;
features: string[];
faq: { q: string; a: string }[];
hasPricing: boolean;
}
export const programs: Program[] = [
{
slug: "personal-training",
title: "Personal Training",
subtitle: "One to One / Small Group",
excerpt: "Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.",
features: ["Trainer dedicato", "Percorso su misura", "Valutazione iniziale e monitoraggio", "Formula one to one o small group (max 3)"],
faq: [
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo lanamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
],
hasPricing: false,
},
{
slug: "performance-class",
title: "Performance Class",
subtitle: "Gruppo max 8 persone",
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso allinterno di un piano più ampio.",
features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
faq: [
{ q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso allinterno di un piano più ampio." },
{ q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
{ q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
{ q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
{ q: "Qual è lintensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità allinterno della stessa sessione, adattate al livello dei partecipanti." },
],
hasPricing: true,
},
{
slug: "coaching",
title: "Coaching",
subtitle: "Programma di allenamento e monitoraggio",
excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nellallenamento.",
longDescription: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).",
features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
faq: [
{ q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
{ q: "Serve attrezzatura?", a: "Il programma viene costruito sullattrezzatura che hai a disposizione, in palestra o a casa." },
{ q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
],
hasPricing: false,
},
{
slug: "pilates-flow",
title: "Pilates Flow",
subtitle: "Movimento e controllo",
excerpt: "Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre losteopatia supporta recupero, prevenzione e benessere globale integrandosi con lallenamento.",
longDescription: "Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con losteopatia, supporta recupero, prevenzione e benessere globale allinterno del percorso di allenamento.",
features: ["Postura e core", "Movimento fluido e controllo", "Integrazione con osteopatia", "Benessere globale"],
faq: [
{ q: "A chi è adatto il Pilates Flow?", a: "A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello." },
{ q: "Serve esperienza precedente?", a: "No: le progressioni sono graduali e seguite da professionisti qualificati." },
{ q: "Come si integra con losteopatia?", a: "Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento." },
],
hasPricing: false,
},
{
slug: "rehab",
title: "Rehab",
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
excerpt: "Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno allattività fisica in modo sicuro, progressivo e personalizzato.",
longDescription: "Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno allattività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.",
features: ["Recupero post-infortunio", "Patologie muscolo-scheletriche", "Supervisione di chinesiologi e osteopati", "Progressione sicura e personalizzata"],
faq: [
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con larea osteopatica." },
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
],
hasPricing: false,
},
{
slug: "nutrition",
title: "Nutrizione",
subtitle: "Alimentazione e stile di vita",
excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
features: ["Piani alimentari su misura", "Integrazione con lallenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
faq: [
{ q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
{ q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
{ q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
],
hasPricing: false,
},
];
export function getProgram(slug: string): Program | undefined {
return programs.find((p) => p.slug === slug);
}
+15
View File
@@ -0,0 +1,15 @@
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
export const serviceIcons: Record<string, string> = {
'one-to-one': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'small-group': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
// Servizi rinominati (modifiche 02): profilazione (grafico/analisi), osteopatia (mani)
'profilazione': 'M4 20V6m5 14V4m5 16v-8m5 8V9M3 20h18',
'osteopatia': 'M8 3v7a4 4 0 008 0V3M6 14c0 4 3 7 6 7s6-3 6-7',
// Riquadri Training con pagina dettaglio
'holistic-class': 'M12 3a3 3 0 110 6 3 3 0 010-6zm0 6v7m-4 3l4-3 4 3',
'reformer-class': 'M3 8h18M3 16h18M6 8v8m12-8v8M9 12h6',
};
+170
View File
@@ -0,0 +1,170 @@
export interface Service {
slug: string;
title: string;
subtitle: string;
excerpt: string;
longDescription: string;
features: string[];
faq: { q: string; a: string }[];
hasPricing: boolean;
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
grid?: boolean;
}
export const services: Service[] = [
{
slug: "one-to-one",
title: "One to One",
subtitle: "Allenamento con un trainer dedicato",
excerpt: "Lallenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati.",
features: [],
faq: [
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo lanamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
],
hasPricing: false,
},
{
slug: "small-group",
title: "Small Group",
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
excerpt: "Lo Small Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.",
longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.",
features: [],
faq: [
{ q: "Quante persone ci sono in un Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
],
hasPricing: false,
grid: false,
},
{
slug: "performance-class",
title: "Performance Class",
subtitle: "Gruppo max 8 persone",
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso allinterno di un piano più ampio.",
features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
faq: [
{ q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso allinterno di un piano più ampio." },
{ q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
{ q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
{ q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
{ q: "Qual è lintensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità allinterno della stessa sessione, adattate al livello dei partecipanti." },
],
hasPricing: true,
},
{
slug: "coaching",
title: "Coaching",
subtitle: "Programma di allenamento e monitoraggio",
excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nellallenamento.",
longDescription: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).",
features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
faq: [
{ q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
{ q: "Serve attrezzatura?", a: "Il programma viene costruito sullattrezzatura che hai a disposizione, in palestra o a casa." },
{ q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
],
hasPricing: false,
},
{
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
slug: "profilazione",
title: "Profilazione",
subtitle: "Check-up e valutazione fisica",
excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.",
longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.",
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
faq: [
{ q: "Quando si fa la profilazione?", a: "Allinizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
{ q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." },
],
hasPricing: false,
},
{
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
slug: "osteopatia",
title: "Osteopatia",
subtitle: "Approccio manuale globale non invasivo",
excerpt: "Losteopatia è una delle colonne portanti dellapproccio integrato di InsanityLab. Non è un servizio aggiunto: è parte della visione.",
longDescription: "Losteopatia è indicata sia in modo preventivo, per chi vuole mantenere il proprio equilibrio corporeo, sia in modo curativo, per chi sta affrontando un problema specifico. È particolarmente utile in sinergia con lallenamento, perché permette di lavorare su compensi e rigidità che altrimenti limiterebbero i progressi. Nel nostro approccio rientrano anche losteopatia neonatale e pediatrica.",
features: [],
faq: [
{ q: "Losteopatia è preventiva o curativa?", a: "Entrambe: è utile sia per mantenere lequilibrio corporeo sia per affrontare un problema specifico, in sinergia con lallenamento." },
{ q: "Trattate anche neonati e bambini?", a: "Sì, il nostro approccio comprende anche losteopatia neonatale e pediatrica." },
{ q: "Serve una prescrizione medica?", a: "Non è necessaria; se disponibile, eventuale documentazione clinica è utile alla prima valutazione." },
],
hasPricing: false,
},
{
slug: "nutrition",
title: "Nutrizione",
subtitle: "Alimentazione e stile di vita",
excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
features: ["Piani alimentari su misura", "Integrazione con lallenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
faq: [
{ q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
{ q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
{ q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
],
hasPricing: false,
},
{
// Approfondimento del riquadro Training "Rehab" (fuori dalla griglia /services).
slug: "rehab",
title: "Rehab",
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
excerpt: "Accompagniamo il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena.",
longDescription: "Il Rehab InsanityLab significa accompagnare il corpo nel recupero della sua funzionalità attraverso il movimento guidato. Non è fisioterapia in senso clinico: è il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento. Il servizio lavora con un ampio spettro di condizioni e ogni situazione viene valutata singolarmente con metodologie evidence based.",
features: [],
faq: [
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con medici specialisti, fisioterapisti e osteopati." },
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
],
hasPricing: false,
grid: false,
},
{
// Approfondimento del riquadro Training "Holistic Class" (ex Pilates Flow / Performance Class).
slug: "holistic-class",
title: "Holistic Class",
subtitle: "Classi fino a 8 persone con trainer certificato",
excerpt: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti.",
longDescription: "La Holistic Class lavora su mobilità articolare, respirazione ed equilibrio. Puoi seguirla da sola o integrarla nei tuoi allenamenti; è adatta a tutti i livelli e ogni sessione dura 40 minuti. Comprende lavoro di Pilates Flow, Mobility e Yoga.",
features: ["Pilates Flow", "Mobility", "Yoga"],
faq: [
{ q: "A chi è adatta la Holistic Class?", a: "A chiunque voglia migliorare mobilità, respirazione ed equilibrio, a ogni livello di partenza." },
{ q: "Posso integrarla con altri allenamenti?", a: "Sì: funziona sia come percorso a sé sia come complemento al tuo programma di allenamento." },
],
hasPricing: false,
grid: false,
},
{
// Approfondimento del riquadro Training "Reformer Class" (nuova).
slug: "reformer-class",
title: "Reformer Class",
subtitle: "Classi fino a 5 persone con macchinari reformer",
excerpt: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti.",
longDescription: "La Reformer Class utilizza i macchinari reformer per il pilates in classi fino a 5 persone. Il lavoro è dedicato a core, postura e mobilità, a basso impatto sulle articolazioni e adatto a tutti i livelli. Ogni lezione dura 40 minuti.",
features: ["Macchinari reformer", "Core, postura e mobilità", "Lavoro a basso impatto sulle articolazioni", "Classi fino a 5 persone", "Lezioni da 40 minuti"],
faq: [
{ q: "Serve esperienza con il reformer?", a: "No: le progressioni sono graduali e seguite da trainer certificati, adatte anche a chi inizia." },
{ q: "Quante persone per classe?", a: "Massimo 5, per garantire attenzione e correzione costante." },
],
hasPricing: false,
grid: false,
},
];
export function getService(slug: string): Service | undefined {
return services.find((p) => p.slug === slug);
}
+17 -12
View File
@@ -2,8 +2,10 @@ export const site = {
name: "InsanityLab", name: "InsanityLab",
phone: "351 7535977", phone: "351 7535977",
email: "info@insanitylab.it", 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", cityLine: "40139 Bologna",
mapQuery: "Via Leandro Alberti 76, 40139 Bologna",
hours: ["Lun Ven 6.00 21.00", "Sab 8.00 19.00 · Dom chiuso"], hours: ["Lun Ven 6.00 21.00", "Sab 8.00 19.00 · Dom chiuso"],
socials: [ socials: [
{ label: "Instagram", url: "https://www.instagram.com/insanitylab" }, { label: "Instagram", url: "https://www.instagram.com/insanitylab" },
@@ -13,22 +15,25 @@ export const site = {
navigation: [ navigation: [
{ label: "Insanitylab", href: "/about" }, { label: "Insanitylab", href: "/about" },
{ label: "Training", href: "/training" }, { label: "Training", href: "/training" },
{ label: "Programmi", href: "/programs" }, { label: "Servizi", href: "/services" },
{ label: "Blog & Edugo", href: "/blog" }, { label: "Blog & Edugo", href: "/blog" },
{ label: "Contatti", href: "/contact" }, { label: "Contatti", href: "/contact" },
{ label: "Promo ed eventi", href: "/promo-eventi" },
], ],
footerTraining: [ footerTraining: [
{ label: "One to one", href: "/training#one-to-one" }, { label: "One to one", href: "/training#one-to-one" },
{ label: "Small groups", href: "/training#small-groups" }, { label: "Small group", href: "/training#small-groups" },
{ label: "Performance class", href: "/training#performance-class" }, { label: "Rehab", href: "/training#rehab" },
{ label: "Fit remote", href: "/training#fit-remote" }, { label: "Holistic class", href: "/training#holistic-class" },
{ label: "Reformer class", href: "/training#reformer-class" },
{ label: "Coaching", href: "/training#coaching" },
], ],
footerPrograms: [ footerServices: [
{ label: "Personal Training", href: "/programs/personal-training" }, { label: "Personal Training", href: "/services/personal-training" },
{ label: "Performance Class", href: "/programs/performance-class" }, { label: "Performance Class", href: "/services/performance-class" },
{ label: "Coaching", href: "/programs/coaching" }, { label: "Coaching", href: "/services/coaching" },
{ label: "Rehab", href: "/programs/rehab" }, { label: "Profilazione", href: "/services/profilazione" },
{ label: "Pilates Flow", href: "/programs/pilates-flow" }, { label: "Osteopatia", href: "/services/osteopatia" },
{ label: "Nutrizione", href: "/programs/nutrition" }, { label: "Nutrizione", href: "/services/nutrition" },
], ],
}; };
+6 -4
View File
@@ -1,6 +1,8 @@
export const trainings = [ export const trainings = [
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Contattaci", href: "/contact" } }, { id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } },
{ id: "small-groups", title: "Small Groups", caption: "Mini gruppi (max 3 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Contattaci", href: "/contact" } }, { id: "small-groups", title: "Small Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } },
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/programs/performance-class" } }, { id: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } },
{ id: "fit-remote", title: "Fit Remote", caption: "Piano di allenamento su misura, ovunque ti alleni.", image: "training-fit-remote", description: "Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.", cta: { label: "Contattaci", href: "/contact" } }, { id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } },
{ id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } },
{ id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } },
] as const; ] as const;
+11 -2
View File
@@ -31,8 +31,17 @@ const user = Astro.locals.user;
<div class="abar"> <div class="abar">
<strong>InsanityLab · Admin</strong> <strong>InsanityLab · Admin</strong>
<span> <span>
{user && user.role === 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin/content">Contenuti</a> <a href="/admin/logout">Esci</a></>} {user && (
{user && user.role !== 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/content">Contenuti</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>} <>
{`Ciao, ${user.username}`}
<a href="/admin">Articoli</a>
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
<a href="/admin/new">Nuovo</a>
<a href="/admin/logout">Esci</a>
<a href="/blog" target="_blank">Vedi sito ↗</a>
</>
)}
</span> </span>
</div> </div>
<div class="awrap"><slot /></div> <div class="awrap"><slot /></div>
+133 -18
View File
@@ -14,12 +14,11 @@ import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
interface Props { title: string; description?: string } interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props; const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
// Overlay annotate: badge coi tag contenuto, solo per utenti loggati con ?annotate=1.
let annotate = false;
if (Astro.url.searchParams.get('annotate') === '1') {
const tok = Astro.cookies.get(SESSION_COOKIE)?.value; const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
annotate = Boolean(tok && getSessionUser(getDb(), tok)); 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;
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -37,7 +36,7 @@ if (Astro.url.searchParams.get('annotate') === '1') {
<script is:inline>document.documentElement.classList.add('js');</script> <script is:inline>document.documentElement.classList.add('js');</script>
</head> </head>
<body> <body>
<Header /> <Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main> <main><slot /></main>
<Footer /> <Footer />
<script> <script>
@@ -74,30 +73,146 @@ if (Astro.url.searchParams.get('annotate') === '1') {
update(); update();
} }
</script> </script>
{annotate && ( {showTags && (
<style is:global> <style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; } [data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; background: #3a2929; color: #fff; .tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
font: 600 10px/1.6 monospace; padding: 0 6px; text-decoration: none; opacity: .85; } background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
.tag-badge:hover { opacity: 1; background: #9c8b70; } .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-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__save { background: #3a2d26; color: #fff; }
.tag-modal__cancel { background: #ddd; }
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
</style> </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__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>
</div>
</div>
</div>
<script is:inline> <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 modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid');
const tmBody = document.getElementById('tm-body');
const tmMsg = document.getElementById('tm-msg');
const tmSave = document.getElementById('tm-save');
let current = null; // { tag, type, el }
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
document.getElementById('tm-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
async function openModal(tag, el) {
tmMsg.textContent = '';
let data;
try {
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
data = await res.json();
} catch { alert('Errore di rete.'); return; }
current = { tag: data.tag, type: data.type, el };
tmTag.textContent = data.tag;
tmGuid.textContent = data.guid || '';
tmBody.innerHTML = '';
if (data.type === 'image') {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
tmBody.appendChild(inp);
const del = document.createElement('button');
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
tmBody.appendChild(del);
del.addEventListener('click', async () => {
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
});
} else {
const ta = document.createElement('textarea');
ta.id = 'tm-value'; ta.value = data.value;
tmBody.appendChild(ta);
(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);
});
}
modal.hidden = false;
}
tmSave.addEventListener('click', async () => {
if (!current) return;
tmMsg.textContent = '';
if (current.type === 'image') {
const file = document.getElementById('tm-file').files[0];
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
const fd = new FormData(); fd.append('file', file);
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
return;
}
const value = document.getElementById('tm-value').value;
const styles = {};
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
const body = { value };
if (Object.keys(styles).length) body.styles = styles;
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
const saved = await r.json();
// Aggiorna in-place il contenuto nel DOM.
if (current.type === 'html') current.el.innerHTML = saved.value;
else current.el.textContent = saved.value;
closeModal();
});
document.querySelectorAll('[data-tag]').forEach((el) => { document.querySelectorAll('[data-tag]').forEach((el) => {
const a = document.createElement('a'); const tag = el.dataset.tag;
a.className = 'tag-badge'; const guid = el.dataset.guid || '';
a.textContent = el.dataset.tag; const badge = document.createElement('span');
a.href = '/admin/content#' + encodeURIComponent(el.dataset.tag); badge.className = 'tag-badge';
a.target = '_blank'; const label = document.createElement('span');
document.body.appendChild(a); label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
const edit = document.createElement('button');
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
edit.addEventListener('click', () => openModal(tag, el));
badge.appendChild(label); badge.appendChild(edit);
document.body.appendChild(badge);
const place = () => { const place = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
a.style.top = window.scrollY + r.top - 14 + 'px'; badge.style.top = window.scrollY + r.top - 16 + 'px';
a.style.left = window.scrollX + r.left + 'px'; badge.style.left = window.scrollX + r.left + 'px';
}; };
place(); place();
addEventListener('scroll', place, { passive: true }); addEventListener('scroll', place, { passive: true });
addEventListener('resize', place); addEventListener('resize', place);
}); });
})();
</script> </script>
)} )}
</body> </body>
+60 -4
View File
@@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session'; export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
export type Role = 'admin' | 'editor'; export type Role = 'admin' | 'superuser' | 'user';
export function hashPassword(plain: string): string { export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12); return bcrypt.hashSync(plain, 12);
@@ -49,10 +49,66 @@ export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token); db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
} }
// Percorsi admin consentiti anche al ruolo editor. // Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/]; // che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
// utenti già autenticati).
const RULES: [RegExp, Role[]][] = [
[/^\/admin\/users(\/|$)/, ['admin']],
[/^\/api\/admin\/users(\/|$)/, ['admin']],
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
];
export function canAccessAdminPath(role: string, pathname: string): boolean { export function canAccessAdminPath(role: string, pathname: string): boolean {
if (role === 'admin') return true; if (role === 'admin') return true;
return EDITOR_ALLOWED.some((re) => re.test(pathname)); for (const [re, roles] of RULES) {
if (re.test(pathname)) return roles.includes(role as Role);
}
return true; // blog, upload, logout, showtags: tutti i loggati
}
export function getUsernameById(db: Database.Database, id: number): string | null {
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
return row?.username ?? null;
}
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
{ id: number; username: string; role: string }[];
}
export function countAdmins(db: Database.Database): number {
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
}
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
{ id: number; username: string; role: string } | undefined;
return row ?? null;
}
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
}
export function deleteUser(db: Database.Database, id: number): void {
db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
export function randomPassword(): string {
return randomBytes(9).toString('base64url'); // ~12 caratteri
}
export function isRole(v: unknown): v is Role {
return v === 'admin' || v === 'superuser' || v === 'user';
}
export function landingFor(role: string): string {
if (role === 'superuser') return '/admin/content';
return '/admin';
} }
+11 -10
View File
@@ -1,13 +1,14 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { getDb } from './db'; import { getDb } from './db';
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed'; import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
import { stylesToClasses } from './style-presets'; import { stylesToClasses } from './style-presets';
interface Entry { value: string; type: ContentType; styles: Record<string, string> } interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
export function syncSeed(db: Database.Database): void { export function syncSeed(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
tx(); tx();
} }
@@ -16,25 +17,25 @@ export function makeContentStore(dbGetter: () => Database.Database) {
const load = (): Map<string, Entry> => { const load = (): Map<string, Entry> => {
if (cache) return cache; if (cache) return cache;
const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as
{ tag: string; type: ContentType; value: string; styles: string }[]; { tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
cache = new Map(rows.map((r) => { cache = new Map(rows.map((r) => {
let styles: Record<string, string> = {}; let styles: Record<string, string> = {};
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ } try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof styles !== 'object' || styles === null) styles = {}; if (typeof styles !== 'object' || styles === null) styles = {};
return [r.tag, { value: r.value, type: r.type, styles }]; return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
})); }));
return cache; return cache;
}; };
return { return {
resolve(tag: string): { value: string; type: ContentType; classes: string } { resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
const hit = load().get(tag); const hit = load().get(tag);
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) }; if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
const seed = seedByTag.get(tag); const seed = seedByTag.get(tag);
if (seed) return { value: seed.value, type: seed.type, classes: '' }; if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
console.warn(`[content] tag sconosciuto: ${tag}`); console.warn(`[content] tag sconosciuto: ${tag}`);
return { value: '', type: 'text', classes: '' }; return { value: '', type: 'text', classes: '', guid: '' };
}, },
invalidate(): void { cache = null; }, invalidate(): void { cache = null; },
}; };
+143 -2
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { contentSeed } from '../data/content-seed'; import { contentSeed } from '../data/content-seed';
const SCHEMA = ` const SCHEMA = `
@@ -50,9 +51,149 @@ export function createDb(path?: string): Database.Database {
if (!userCols.some((c) => c.name === 'role')) { if (!userCols.some((c) => c.name === 'role')) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`); db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
} }
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); // Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
// GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
if (!contentCols.some((c) => c.name === 'guid')) {
db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
}
// Autore articoli: colonna nullable (post storici restano senza autore).
const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
if (!postCols.some((c) => c.name === 'author_id')) {
db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
}
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
const TAG_RENAMES: [string, string][] = [
['home.programs.', 'home.services.'],
['footer.programs.', 'footer.services.'],
['programs.', 'services.'],
['program.', 'service.'],
];
const renameUpd = db.prepare('UPDATE OR IGNORE content_blocks SET tag = ? || substr(tag, ?) WHERE tag LIKE ?');
const renameDel = db.prepare('DELETE FROM content_blocks WHERE tag LIKE ?');
const renameTx = db.transaction(() => {
for (const [oldP, newP] of TAG_RENAMES) {
renameUpd.run(newP, oldP.length + 1, `${oldP}%`);
renameDel.run(`${oldP}%`);
}
// Tag singolo, non coperto dai prefissi: uguaglianza esatta.
db.prepare("UPDATE OR IGNORE content_blocks SET tag = 'footer.col.services-title' WHERE tag = 'footer.col.programs-title'").run();
db.prepare("DELETE FROM content_blocks WHERE tag = 'footer.col.programs-title'").run();
// I valori di default che nominavano la sezione cambiano con la rinomina. Il seed è
// INSERT OR IGNORE e non tocca le righe esistenti, quindi si aggiornano qui — ma solo
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
// il nuovo valore è quello del seed corrente per lo stesso tag.
const OLD_SECTION_DEFAULTS: [string, string][] = [
['global.nav.3.label', 'Programmi'],
['footer.col.services-title', 'Programmi'],
['home.services.title', 'Programmi'],
['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.'],
['services.meta.title', 'Programmi'],
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
['services.hero.title', 'Programmi'],
["services.intro.body", "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."],
['service.side.others-label', 'Altri programmi'],
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
];
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
const next = seedValue.get(tag);
if (next !== undefined) updValue.run(next, tag, oldValue);
}
});
renameTx();
// Modifiche 03: "Personal Training" → "One to One" (nuovo slug one-to-one). Rinomina i tag
// esistenti prima del seed per preservare le modifiche da pannello; il title di default viene
// aggiornato solo se non modificato. La nuova pagina "Small Group" (slug small-group) è un
// servizio nuovo e viene inserita dal seed più sotto. Idempotente.
const oneToOneTx = db.transaction(() => {
renameUpd.run('service.one-to-one.', 'service.personal-training.'.length + 1, 'service.personal-training.%');
renameDel.run('service.personal-training.%');
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal Training'").run();
});
oneToOneTx();
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
syncTx(); syncTx();
// Modifiche 02 (pagina Servizi): "Pilates Flow" → "Profilazione" e "Rehab" → "Osteopatia".
// I nuovi servizi hanno slug nuovi (tag service.profilazione.* / service.osteopatia.*) e
// vengono inseriti dal seed qui sopra. Restano da aggiornare le righe EXISTING il cui valore
// cambia: label del footer e meta description. UPDATE guardato dal vecchio valore → idempotente
// e non sovrascrive eventuali modifiche fatte dal pannello.
const SERVIZI_02: [string, string, string][] = [
['footer.services.4.label', 'Rehab', 'Profilazione'],
['footer.services.5.label', 'Pilates Flow', 'Osteopatia'],
['services.meta.description',
'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.',
'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.'],
// Pagina Training: riquadri passati a One to One, Small Group, Rehab, Holistic Class,
// Reformer Class, Coaching. Label footer + meta aggiornate (righe 5-6 sono nuovi tag).
['footer.training.2.label', 'Small groups', 'Small group'],
['footer.training.3.label', 'Performance class', 'Rehab'],
['footer.training.4.label', 'Fit remote', 'Holistic class'],
['training.meta.description',
'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.',
'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.'],
];
const upd02 = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
const upd02Tx = db.transaction(() => { for (const [tag, oldV, newV] of SERVIZI_02) upd02.run(newV, tag, oldV); });
upd02Tx();
// Metodo (home): step riscritti dal pannello in dev ma mai propagati in produzione
// (il DB non è versionato: gli edit vivono solo nel volume). Qui si allineano le righe
// di prod ancora ai vecchi default del seed, senza toccare eventuali modifiche successive.
const METODO: [string, string, string][] = [
['method.step.1.text', 'Dove raccogliamo unanamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', 'La base di partenza per definire le priorità del percorso di ciascun cliente.'],
['method.step.2.title', 'Prima seduta operativa', 'Check-up fisico'],
['method.step.2.text', 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', 'Profilazione fisica per definire lo stato di salute iniziale del cliente.'],
['method.step.3.title', 'Plicometria', 'Insanity plan'],
['method.step.3.text', 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', 'Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.'],
['method.step.4.title', 'Analisi antropometriche', 'Monitoraggio costante'],
['method.step.4.text', 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', 'Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.'],
];
const updMetodoTx = db.transaction(() => { for (const [tag, oldV, newV] of METODO) upd02.run(newV, tag, oldV); });
updMetodoTx();
// Modifiche 03: due ingressi dello stesso stabile → indirizzo esteso in footer e contatti.
// Il tag global.contact.map-query è nuovo (inserito dal seed); qui si aggiorna solo l'indirizzo
// già esistente, se non modificato dal pannello. Idempotente.
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
// Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
// rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta).
const ABOUT_02: [string, string, string][] = [
['about.about.body', '%unire fitness e salute in un unico percorso%',
"InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."],
['about.convenzioni.item1', '%small group training e classi di gruppo%',
'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'],
['about.convenzioni.item2', '%consulenza gratuita preliminare%',
'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'],
['about.studios.body1', '%della performance. Più che un semplice insieme%',
"IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."],
];
const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?');
const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); });
updAboutTx();
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
bfTx();
// Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
return db; return db;
} }
+14 -5
View File
@@ -2,21 +2,21 @@ import type Database from 'better-sqlite3';
export interface Post { export interface Post {
id: number; title: string; slug: string; category: string; excerpt: string; id: number; title: string; slug: string; category: string; excerpt: string;
body_html: string; cover: string | null; draft: number; body_html: string; cover: string | null; draft: number; author_id: number | null;
published_at: string | null; created_at: string; updated_at: string; published_at: string | null; created_at: string; updated_at: string;
} }
export interface PostInput { export interface PostInput {
title: string; slug: string; category: string; excerpt: string; title: string; slug: string; category: string; excerpt: string;
body_html: string; cover?: string | null; draft: boolean; body_html: string; cover?: string | null; draft: boolean; author_id?: number | null;
} }
export function createPost(db: Database.Database, input: PostInput): number { export function createPost(db: Database.Database, input: PostInput): number {
const res = db.prepare( const res = db.prepare(
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at) `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id,
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)` CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 }); ).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
return Number(res.lastInsertRowid); return Number(res.lastInsertRowid);
} }
@@ -72,3 +72,12 @@ export function listArchiveMonths(db: Database.Database): { month: string; count
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC` FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
).all() as { month: string; count: number }[]; ).all() as { month: string; count: number }[];
} }
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
}
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
if (role === 'admin' || role === 'superuser') return true;
return post.author_id === userId;
}
+8
View File
@@ -0,0 +1,8 @@
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
export function safeNext(next: string | null): string {
if (next && next.includes('\\')) return '/';
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
if (next.startsWith('/api/')) return '/';
return next;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineMiddleware } from 'astro:middleware'; import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db'; import { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath } from './lib/auth'; import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => { export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url; const { pathname } = context.url;
@@ -26,7 +26,7 @@ export const onRequest = defineMiddleware((context, next) => {
status: 403, headers: { 'Content-Type': 'application/json' }, status: 403, headers: { 'Content-Type': 'application/json' },
}); });
} }
return context.redirect('/admin/content'); return context.redirect(landingFor(user.role));
} }
context.locals.user = user; context.locals.user = user;
+1 -39
View File
@@ -2,7 +2,6 @@
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro'; import PageHero from '../components/PageHero.astro';
import Timeline from '../components/about/Timeline.astro';
import { team } from '../data/team'; import { team } from '../data/team';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
import TImg from '../components/content/TImg.astro'; import TImg from '../components/content/TImg.astro';
@@ -37,7 +36,7 @@ export const prerender = false;
<div> <div>
<T tag="about.direzione.title" as="h2" /> <T tag="about.direzione.title" as="h2" />
<T tag="about.direzione.body" as="p" /> <T tag="about.direzione.body" as="p" />
<a class="btn" href="/programs">{t('about.direzione.cta')}</a> <a class="btn" href="/services">{t('about.direzione.cta')}</a>
</div> </div>
<div> <div>
<T tag="about.approccio.title" as="h2" /> <T tag="about.approccio.title" as="h2" />
@@ -63,8 +62,6 @@ export const prerender = false;
</div> </div>
</section> </section>
<Timeline />
<section class="section section--dark"> <section class="section section--dark">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
@@ -121,25 +118,6 @@ export const prerender = false;
</div> </div>
</section> </section>
<section class="section section--alt">
<div class="container">
<div class="section-heading">
<T tag="about.metodo.title" as="h2" />
<T tag="about.metodo.body" as="p" />
</div>
<div class="about-img-row">
<div class="about-img-row__text section-heading">
<T tag="about.metodo.closing" as="p" />
</div>
{imgOf('about-team-group') && (
<div class="round-img">
<TImg tag="about.metodo.image" src={imgOf('about-team-group')!} alt="Il team InsanityLab" widths={[360, 720]} sizes="360px" />
</div>
)}
</div>
</div>
</section>
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="about-img-row"> <div class="about-img-row">
@@ -158,22 +136,6 @@ export const prerender = false;
</div> </div>
</section> </section>
<section class="section section--alt">
<div class="container">
<div class="about-img-row">
{imgOf('about-vision') && (
<div class="round-img">
<TImg tag="about.vision.image" src={imgOf('about-vision')!} alt="La comunità InsanityLab" widths={[360, 720]} sizes="360px" />
</div>
)}
<div class="about-img-row__text section-heading">
<T tag="about.vision.title" as="h2" />
<T tag="about.vision.body1" as="p" />
<T tag="about.vision.body2" as="p" />
</div>
</div>
</div>
</section>
</Base> </Base>
<style> <style>
+2 -2
View File
@@ -9,13 +9,13 @@ export const prerender = false;
const GROUP_BY_PREFIX: Record<string, string> = { const GROUP_BY_PREFIX: Record<string, string> = {
home: 'Home', home: 'Home',
about: 'About', team: 'About', method: 'About', about: 'About', team: 'About', method: 'About',
program: 'Programmi', programs: 'Programmi', pricing: 'Programmi', service: 'Servizi', services: 'Servizi', pricing: 'Servizi',
training: 'Training', training: 'Training',
contact: 'Contatti', form: 'Contatti', contact: 'Contatti', form: 'Contatti',
global: 'Globali', header: 'Globali', footer: 'Globali', global: 'Globali', header: 'Globali', footer: 'Globali',
agenda: 'Globali', partner: 'Globali', notfound: 'Globali', agenda: 'Globali', partner: 'Globali', notfound: 'Globali',
}; };
const GROUP_ORDER = ['Home', 'About', 'Programmi', 'Training', 'Contatti', 'Globali', 'Altro']; const GROUP_ORDER = ['Home', 'About', 'Servizi', 'Training', 'Contatti', 'Globali', 'Altro'];
interface Row { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null } interface Row { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }
const rows = getDb().prepare( const rows = getDb().prepare(
+3 -1
View File
@@ -2,10 +2,12 @@
import Admin from '../../../layouts/Admin.astro'; import Admin from '../../../layouts/Admin.astro';
import PostForm from '../../../components/admin/PostForm.astro'; import PostForm from '../../../components/admin/PostForm.astro';
import { getDb } from '../../../lib/db'; import { getDb } from '../../../lib/db';
import { getPostById } from '../../../lib/posts'; import { getPostById, canModifyPost } from '../../../lib/posts';
export const prerender = false; export const prerender = false;
const post = getPostById(getDb(), Number(Astro.params.id)); const post = getPostById(getDb(), Number(Astro.params.id));
if (!post) return Astro.redirect('/admin'); if (!post) return Astro.redirect('/admin');
const u = Astro.locals.user!;
if (!canModifyPost(u.role, u.id, post)) return Astro.redirect('/admin');
--- ---
<Admin title={`Modifica: ${post.title}`}> <Admin title={`Modifica: ${post.title}`}>
<h1>Modifica articolo</h1> <h1>Modifica articolo</h1>
+8 -3
View File
@@ -1,21 +1,26 @@
--- ---
import Admin from '../../layouts/Admin.astro'; import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { listAllPosts } from '../../lib/posts'; import { listAllPosts, listByAuthor } from '../../lib/posts';
import { listUsers } from '../../lib/auth';
export const prerender = false; export const prerender = false;
const posts = listAllPosts(getDb()); const user = Astro.locals.user!;
const isManager = user.role === 'superuser' || user.role === 'admin';
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' }); const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
--- ---
<Admin title="Articoli"> <Admin title="Articoli">
<h1>Articoli</h1> <h1>Articoli</h1>
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p> <p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
<table> <table>
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead> <thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<tbody> <tbody>
{posts.map((p) => ( {posts.map((p) => (
<tr> <tr>
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td> <td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
<td>{p.category}</td> <td>{p.category}</td>
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td> <td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td> <td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td> <td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
+95
View File
@@ -0,0 +1,95 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { listUsers } from '../../lib/auth';
export const prerender = false;
const users = listUsers(getDb());
const me = Astro.locals.user!;
---
<Admin title="Utenti">
<h1>Utenti</h1>
<h2>Nuovo utente</h2>
<form id="new-user" style="max-width:420px">
<input class="afield" name="username" placeholder="Nome utente" required />
<input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
<select class="afield" name="role">
<option value="user">user</option>
<option value="superuser">superuser</option>
<option value="admin">admin</option>
</select>
<button class="abtn" type="submit">Crea</button>
<span class="form-msg" id="new-msg"></span>
</form>
<h2 style="margin-top:30px">Elenco</h2>
<table>
<thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr data-id={u.id}>
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
<td>
<select class="role-sel" data-id={u.id}>
{['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
</select>
</td>
<td>
<button class="abtn" data-reset={u.id}>Reset password</button>
{u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
</td>
</tr>
))}
</tbody>
</table>
<p class="form-msg" id="row-msg"></p>
</Admin>
<script>
const rowMsg = document.getElementById('row-msg')!;
const show = (el: HTMLElement, text: string, ok = false) => {
el.textContent = text;
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
};
document.getElementById('new-user')!.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target as HTMLFormElement;
const body = {
username: (f.elements.namedItem('username') as HTMLInputElement).value,
password: (f.elements.namedItem('password') as HTMLInputElement).value,
role: (f.elements.namedItem('role') as HTMLSelectElement).value,
};
const res = await fetch('/api/admin/users', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const msg = document.getElementById('new-msg') as HTMLElement;
if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
else show(msg, (await res.json()).error ?? 'Errore');
});
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
sel.addEventListener('change', async () => {
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
});
if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
}));
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Generare una nuova password per questo utente?')) return;
const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
else show(rowMsg, (await res.json()).error ?? 'Errore');
}));
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo utente?')) return;
const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
if (res.ok) location.reload();
else show(rowMsg, (await res.json()).error ?? 'Errore');
}));
</script>
+15
View File
@@ -2,11 +2,26 @@ import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db'; import { getDb } from '../../../../lib/db';
import { applyContentUpdate } from '../../../../lib/content-update'; import { applyContentUpdate } from '../../../../lib/content-update';
import { invalidateContentCache } from '../../../../lib/content'; import { invalidateContentCache } from '../../../../lib/content';
import { seedByTag } from '../../../../data/content-seed';
export const prerender = false; export const prerender = false;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const 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;
if (!row) return json(404, { error: 'Tag inesistente.' });
return json(200, {
tag: row.tag,
guid: row.guid ?? '',
type: row.type,
value: row.value,
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
});
};
export const PUT: APIRoute = async ({ params, request, locals }) => { export const PUT: APIRoute = async ({ params, request, locals }) => {
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+11 -5
View File
@@ -1,15 +1,17 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db'; import { getDb } from '../../../../lib/db';
import { getPostById, updatePost, deletePost } from '../../../../lib/posts'; import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts';
import { parsePostBody } from '../../../../lib/post-body'; import { parsePostBody } from '../../../../lib/post-body';
export const prerender = false; export const prerender = false;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const PUT: APIRoute = async ({ params, request }) => { export const PUT: APIRoute = async ({ params, request, locals }) => {
const id = Number(params.id); const id = Number(params.id);
if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' }); const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi modificare questo articolo.' });
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const parsed = parsePostBody(data); const parsed = parsePostBody(data);
@@ -23,7 +25,11 @@ export const PUT: APIRoute = async ({ params, request }) => {
} }
}; };
export const DELETE: APIRoute = ({ params }) => { export const DELETE: APIRoute = ({ params, locals }) => {
deletePost(getDb(), Number(params.id)); const id = Number(params.id);
const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
deletePost(getDb(), id);
return json(200, { ok: true }); return json(200, { ok: true });
}; };
+2 -2
View File
@@ -7,13 +7,13 @@ export const prerender = false;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request, locals }) => {
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const parsed = parsePostBody(data); const parsed = parsePostBody(data);
if ('error' in parsed) return json(400, { error: parsed.error }); if ('error' in parsed) return json(400, { error: parsed.error });
try { try {
const id = createPost(getDb(), parsed.value); const id = createPost(getDb(), { ...parsed.value, author_id: locals.user!.id });
return json(201, { id, slug: parsed.value.slug }); return json(201, { id, slug: parsed.value.slug });
} catch (err: any) { } catch (err: any) {
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' }); if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+18
View File
@@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, cookies, locals }) => {
const role = locals.user!.role;
if (role !== 'superuser' && role !== 'admin') return json(403, { error: 'Permessi insufficienti' });
let data: { on?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (data.on) {
cookies.set('showtags', '1', { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD });
} else {
cookies.delete('showtags', { path: '/' });
}
return json(200, { on: Boolean(data.on) });
};
+19
View File
@@ -0,0 +1,19 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../../lib/db';
import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const DELETE: APIRoute = ({ params, locals }) => {
const id = Number(params.id);
const target = getUserById(getDb(), id);
if (!target) return json(404, { error: 'Utente inesistente.' });
if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' });
if (target.role === 'admin' && countAdmins(getDb()) <= 1) {
return json(400, { error: 'Deve restare almeno un admin.' });
}
deleteUser(getDb(), id);
return json(200, { ok: true });
};
@@ -0,0 +1,16 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../../lib/db';
import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = ({ params }) => {
const id = Number(params.id);
if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
const password = randomPassword();
updateUserPassword(getDb(), id, hashPassword(password));
// La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna.
return json(200, { password });
};
+21
View File
@@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../../lib/db';
import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const PUT: APIRoute = async ({ params, request }) => {
const id = Number(params.id);
const target = getUserById(getDb(), id);
if (!target) return json(404, { error: 'Utente inesistente.' });
let data: { role?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) {
return json(400, { error: 'Deve restare almeno un admin.' });
}
updateUserRole(getDb(), id, data.role);
return json(200, { ok: true });
};
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { createUser, listUsers, isRole } from '../../../../lib/auth';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) });
export const POST: APIRoute = async ({ request }) => {
let data: { username?: unknown; password?: unknown; role?: unknown };
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const username = String(data.username ?? '').trim();
const password = String(data.password ?? '');
if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' });
if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
try {
const id = createUser(getDb(), username, password, data.role);
return json(201, { id });
} catch (err: any) {
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' });
throw err;
}
};
+4 -1
View File
@@ -3,18 +3,21 @@ import Base from '../../layouts/Base.astro';
import Sidebar from '../../components/blog/Sidebar.astro'; import Sidebar from '../../components/blog/Sidebar.astro';
import { getDb } from '../../lib/db'; import { getDb } from '../../lib/db';
import { getPublishedBySlug } from '../../lib/posts'; import { getPublishedBySlug } from '../../lib/posts';
import { getUsernameById } from '../../lib/auth';
export const prerender = false; export const prerender = false;
const post = getPublishedBySlug(getDb(), Astro.params.slug!); const post = getPublishedBySlug(getDb(), Astro.params.slug!);
if (!post) return new Response(null, { status: 404 }); if (!post) return new Response(null, { status: 404 });
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }); const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null;
const byline = author ?? 'Staff InsanityLab';
--- ---
<Base title={post.title} description={post.excerpt}> <Base title={post.title} description={post.excerpt}>
<section class="section"> <section class="section">
<div class="container art"> <div class="container art">
<article> <article>
{post.cover && <img class="art__cover" src={post.cover} alt="" />} {post.cover && <img class="art__cover" src={post.cover} alt="" />}
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p> <p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
<h1>{post.title}</h1> <h1>{post.title}</h1>
<div class="art__body" set:html={post.body_html} /> <div class="art__body" set:html={post.body_html} />
</article> </article>
+18 -9
View File
@@ -6,15 +6,18 @@ import contactHero from '../assets/img/contact-hero.jpg';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
import { t, getImageOverride } from '../lib/content'; import { t, getImageOverride } from '../lib/content';
export const prerender = false; 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')}> <Base title={t('contact.meta.title')} description={t('contact.meta.description')}>
<PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} /> <PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} />
<section class="section"> <section class="section">
<div class="container cgrid"> <div class="container cgrid">
<div class="cmap" id="map-box"> <div class="cmap" id="map-box" data-maps-query={mapsQuery}>
<button class="btn btn--dark" id="map-load">{t('contact.map.button')}</button> <div class="cmap__consent">
<p class="cmap__note">{t('contact.map.note')}</p> <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> </div>
<ContactForm variant="full" /> <ContactForm variant="full" />
</div> </div>
@@ -40,18 +43,24 @@ const mapsQuery = encodeURIComponent(`${t('global.contact.address')}, ${t('globa
<style> <style>
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; } .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 { background: var(--c-bg-alt); min-height: 420px; display: flex; align-items: center; justify-content: center; }
.cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; } .cmap iframe { display: block; width: 100%; height: 100%; min-height: 420px; border: 0; }
.cmap__note { font-size: .8rem; color: var(--c-text-light); } .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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; } .cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
.cinvite { max-width: 640px; } .cinvite { max-width: 640px; }
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } } @media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
</style> </style>
<script define:vars={{ mapsQuery }}> <script>
// GDPR: la mappa Google (terza parte) viene caricata solo dopo consenso esplicito.
document.getElementById('map-load')?.addEventListener('click', () => { document.getElementById('map-load')?.addEventListener('click', () => {
const box = document.getElementById('map-box'); 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> </script>
+52
View File
@@ -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>
+2 -6
View File
@@ -5,9 +5,7 @@ import InfoColumns from '../components/home/InfoColumns.astro';
import FeatureBlocks from '../components/home/FeatureBlocks.astro'; import FeatureBlocks from '../components/home/FeatureBlocks.astro';
import QuoteBanner from '../components/home/QuoteBanner.astro'; import QuoteBanner from '../components/home/QuoteBanner.astro';
import TrainingCards from '../components/home/TrainingCards.astro'; import TrainingCards from '../components/home/TrainingCards.astro';
import ProgramsGrid from '../components/home/ProgramsGrid.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 MethodSteps from '../components/home/MethodSteps.astro';
import PartnersStrip from '../components/home/PartnersStrip.astro'; import PartnersStrip from '../components/home/PartnersStrip.astro';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
@@ -26,9 +24,7 @@ export const prerender = false;
</div> </div>
</section> </section>
<TrainingCards /> <TrainingCards />
<ProgramsGrid /> <ServicesGrid />
<Agenda />
<TeamSection />
<MethodSteps /> <MethodSteps />
<PartnersStrip /> <PartnersStrip />
</Base> </Base>
+53
View File
@@ -0,0 +1,53 @@
---
import Base from '../layouts/Base.astro';
import { getDb } from '../lib/db';
import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
import { rateLimit } from '../lib/rate-limit';
import { safeNext } from '../lib/safe-next';
export const prerender = false;
const dest = safeNext(Astro.url.searchParams.get('next'));
// Già loggato → via dal login.
const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
let error = '';
if (Astro.request.method === 'POST') {
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
error = 'Troppi tentativi: riprova tra 15 minuti.';
} else {
const form = await Astro.request.formData();
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
if (token) {
Astro.cookies.set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/',
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
});
return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
}
error = 'Credenziali non valide.';
}
}
---
<Base title="Accedi" description="Area riservata InsanityLab">
<section class="section">
<div class="container login-wrap">
<h1 class="section-heading">Accedi</h1>
{error && <p class="login-err">{error}</p>}
<form method="post" class="login-form">
<input type="hidden" name="next" value={dest} />
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button class="btn btn--dark" type="submit">Entra</button>
</form>
</div>
</section>
</Base>
<style>
.login-wrap { max-width: 380px; margin: 0 auto; }
.login-form { display: grid; gap: 14px; margin-top: 20px; }
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
.login-err { color: #a33; }
</style>
+67
View File
@@ -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>
+248
View File
@@ -0,0 +1,248 @@
---
import Base from '../layouts/Base.astro';
// 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;
---
<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>
</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>
</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>
</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>
</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>
</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>
</section>
</div>
</div>
</Base>
<script>
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
document.querySelectorAll('.lp .faq-block .faq-q').forEach((q) => {
const block = q.closest('.faq-block');
const toggle = q.querySelector('.faq-toggle');
q.setAttribute('role', 'button');
q.setAttribute('tabindex', '0');
q.setAttribute('aria-expanded', 'false');
const set = (open: boolean) => {
block?.classList.toggle('is-open', open);
q.setAttribute('aria-expanded', String(open));
if (toggle) toggle.textContent = open ? '' : '+';
};
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
q.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
});
});
</script>
<style>
.lp { background: #ECE7DC; }
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
/* HERO */
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
.lp .badge {
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
color: #C4A882; font-weight: 500; text-transform: uppercase;
}
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
.lp .btn-sand {
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
text-decoration: none;
}
/* LAUNCH */
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
/* PERCHE 5 */
.lp .why5 { padding: 48px 64px 8px; }
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
/* CARDS */
.lp .cards-wrap { padding: 32px 64px 72px; }
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
.lp .card.featured { border: 2px solid #2D2D2D; }
.lp .featured-badge {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
}
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
.lp .card .buy {
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
}
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
/* FAQ */
.lp .faq-wrap { padding: 72px 64px; }
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
.lp .faq-toggle {
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
transition: background .2s ease, color .2s ease, border-color .2s ease;
}
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
.lp .faq-block.is-open .faq-a { display: block; }
.lp .faq-a .hl { color: #8A6E3F; }
@media (max-width: 700px) {
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
.lp .cards { grid-template-columns: 1fr; }
.lp .hero h1 { font-size: 32px; }
.lp .launch h1 { font-size: 28px; }
}
</style>
@@ -2,66 +2,68 @@
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro'; import PageHero from '../../components/PageHero.astro';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { programs, getProgram } from '../../data/programs'; import { services, getService } from '../../data/services';
import { performanceClassPricing } from '../../data/pricing'; import { performanceClassPricing } from '../../data/pricing';
import { programIcons } from '../../data/program-icons'; import { serviceIcons } from '../../data/service-icons';
import T from '../../components/content/T.astro'; import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro'; import TImg from '../../components/content/TImg.astro';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
export const prerender = false; export const prerender = false;
const program = getProgram(Astro.params.slug!); const service = getService(Astro.params.slug!);
if (!program) return new Response(null, { status: 404 }); if (!service) return new Response(null, { status: 404 });
const slug = program.slug; const slug = service.slug;
const others = programs.filter((p) => p.slug !== program.slug); const others = services.filter((p) => p.slug !== service.slug && p.grid !== false);
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
const heroImg = imgOf(program.slug); const heroImg = imgOf(service.slug);
// Categorie come da Figma (Performance class.pdf), non quelle del blog → tag program.side.category.1-4 // Categorie come da Figma (Performance class.pdf), non quelle del blog → tag service.side.category.1-4
const categoryIndexes = [1, 2, 3, 4]; const categoryIndexes = [1, 2, 3, 4];
// Ordine card pricing come da Figma: tan, taupe, dark // Ordine card pricing come da Figma: tan, taupe, dark
const tones = ['tan', 'taupe', 'dark']; const tones = ['tan', 'taupe', 'dark'];
--- ---
<Base title={t(`program.${slug}.title`)} description={t(`program.${slug}.excerpt`)}> <Base title={t(`service.${slug}.title`)} description={t(`service.${slug}.excerpt`)}>
<PageHero title={t(`program.${slug}.title`).toUpperCase()} bgImage={heroImg} /> <PageHero title={t(`service.${slug}.title`).toUpperCase()} bgImage={heroImg} />
<section class="section"> <section class="section">
<div class="container prog"> <div class="container prog">
<div> <div>
<h2>{t(`program.${slug}.title`).toUpperCase()}</h2> <h2>{t(`service.${slug}.title`).toUpperCase()}</h2>
<T tag={`program.${slug}.long-description`} as="p" /> <T tag={`service.${slug}.long-description`} as="p" class="prog__desc" />
{service.features.length > 0 && (
<ul class="prog__features"> <ul class="prog__features">
{program.features.map((_, i) => <T tag={`program.${slug}.feature.${i + 1}`} as="li" />)} {service.features.map((_, i) => <T tag={`service.${slug}.feature.${i + 1}`} as="li" />)}
</ul> </ul>
)}
{heroImg && ( {heroImg && (
<TImg tag={`program.${slug}.image`} src={heroImg} alt={t(`program.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" /> <TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
)} )}
<div class="prog__faq"> <div class="prog__faq">
{program.faq.map((_, i) => ( {service.faq.map((_, i) => (
<details open={i === 0}> <details open={i === 0}>
<summary><T tag={`program.${slug}.faq.${i + 1}.q`} /></summary> <summary><T tag={`service.${slug}.faq.${i + 1}.q`} /></summary>
<T tag={`program.${slug}.faq.${i + 1}.a`} as="p" /> <T tag={`service.${slug}.faq.${i + 1}.a`} as="p" />
</details> </details>
))} ))}
</div> </div>
</div> </div>
<aside class="prog__side"> <aside class="prog__side">
<T tag="program.side.categories-label" as="h3" /> <T tag="service.side.categories-label" as="h3" />
{categoryIndexes.map((n) => <T tag={`program.side.category.${n}`} as="p" class="prog__cat" />)} {categoryIndexes.map((n) => <T tag={`service.side.category.${n}`} as="p" class="prog__cat" />)}
<T tag="program.side.others-label" as="h3" /> <T tag="service.side.others-label" as="h3" />
{others.map((o) => ( {others.map((o) => (
<a class="prog__other" href={`/programs/${o.slug}`}> <a class="prog__other" href={`/services/${o.slug}`}>
<span class="prog__icon"> <span class="prog__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={programIcons[o.slug]} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={serviceIcons[o.slug]} /></svg>
</span> </span>
<T tag={`program.${o.slug}.title`} /> <T tag={`service.${o.slug}.title`} />
</a> </a>
))} ))}
</aside> </aside>
</div> </div>
</section> </section>
{program.hasPricing && ( {service.hasPricing && (
<section class="section section--alt"> <section class="section section--alt">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
@@ -72,18 +74,18 @@ const tones = ['tan', 'taupe', 'dark'];
<details class="price__group" open={g.open}> <details class="price__group" open={g.open}>
<summary>{g.open ? '+' : ''} <T tag={`pricing.group.${gi + 1}.label`} /></summary> <summary>{g.open ? '+' : ''} <T tag={`pricing.group.${gi + 1}.label`} /></summary>
{g.plans.length === 0 ? ( {g.plans.length === 0 ? (
<T tag="program.pricing.empty-note" as="p" class="price__empty" /> <T tag="service.pricing.empty-note" as="p" class="price__empty" />
) : ( ) : (
<div class="price__row"> <div class="price__row">
{g.plans.map((p, i) => ( {g.plans.map((p, i) => (
<div class:list={['price__card', `price__card--${tones[i % 3]}`]}> <div class:list={['price__card', `price__card--${tones[i % 3]}`]}>
<p class="price__amount">{p.monthly}€</p> <p class="price__amount">{p.monthly}€</p>
<T tag="program.pricing.per" as="p" class="price__per" /> <T tag="service.pricing.per" as="p" class="price__per" />
<p class="price__entries">{p.entries} {p.entries === 1 ? t('program.pricing.entries-singular') : t('program.pricing.entries-plural')}</p> <p class="price__entries">{p.entries} {p.entries === 1 ? t('service.pricing.entries-singular') : t('service.pricing.entries-plural')}</p>
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.note`} as="p" /> <T tag={`pricing.group.${gi + 1}.plan.${i + 1}.note`} as="p" />
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.installments`} as="p" /> <T tag={`pricing.group.${gi + 1}.plan.${i + 1}.installments`} as="p" />
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.best`} as="p" class="price__best" /> <T tag={`pricing.group.${gi + 1}.plan.${i + 1}.best`} as="p" class="price__best" />
<a class="btn btn--light" href="/contact">{t('program.pricing.buy-cta')}</a> <a class="btn btn--light" href="/contact">{t('service.pricing.buy-cta')}</a>
</div> </div>
))} ))}
</div> </div>
@@ -97,6 +99,7 @@ const tones = ['tan', 'taupe', 'dark'];
<style> <style>
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; } .prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
.prog :global(.prog__desc) { white-space: pre-line; }
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; } .prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
.prog__features li::marker { color: var(--c-accent); } .prog__features li::marker { color: var(--c-accent); }
.prog__img { width: 100%; height: auto; margin-bottom: 30px; } .prog__img { width: 100%; height: auto; margin-bottom: 30px; }
@@ -2,35 +2,38 @@
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro'; import PageHero from '../../components/PageHero.astro';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { programs } from '../../data/programs'; import { services } from '../../data/services';
import T from '../../components/content/T.astro'; import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro'; import TImg from '../../components/content/TImg.astro';
import { t } from '../../lib/content'; import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
const gridServices = services.filter((p) => p.grid !== false);
// Questi servizi rimandano alla pagina Training invece che alla scheda di dettaglio
const TRAINING_LINK = new Set(['one-to-one', 'performance-class']);
const moreHref = (slug: string) => (TRAINING_LINK.has(slug) ? '/training' : `/services/${slug}`);
export const prerender = false; export const prerender = false;
--- ---
<Base title={t('programs.meta.title')} description={t('programs.meta.description')}> <Base title={t('services.meta.title')} description={t('services.meta.description')}>
<PageHero title={t('programs.hero.title')} /> <PageHero title={t('services.hero.title')} />
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<T tag="programs.intro.title" as="h2" /> <T tag="services.intro.body" as="p" />
<T tag="programs.intro.body" as="p" />
</div> </div>
<div class="plist"> <div class="plist">
{programs.map((p, i) => ( {gridServices.map((p, i) => (
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}> <article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
{imgOf(p.slug) && ( {imgOf(p.slug) && (
<a class="plist__photo" href={`/programs/${p.slug}`}> <a class="plist__photo" href={`/services/${p.slug}`}>
<TImg tag={`program.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`program.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" /> <TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
</a> </a>
)} )}
<div class="plist__body"> <div class="plist__body">
<T tag={`program.${p.slug}.title`} as="h3" /> <T tag={`service.${p.slug}.title`} as="h3" />
<T tag={`program.${p.slug}.subtitle`} as="p" class="plist__sub" /> <T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
<T tag={`program.${p.slug}.excerpt`} as="p" /> <T tag={`service.${p.slug}.excerpt`} as="p" />
<a class="plist__more" href={`/programs/${p.slug}`}>{t('programs.card.more-label')}</a> <a class="plist__more" href={moreHref(p.slug)}>{t('services.card.more-label')}</a>
</div> </div>
</article> </article>
))} ))}
+3 -3
View File
@@ -1,16 +1,16 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { getDb } from '../lib/db'; import { getDb } from '../lib/db';
import { programs } from '../data/programs'; import { services } from '../data/services';
export const prerender = false; export const prerender = false;
const STATIC = ['/', '/about', '/contact', '/training', '/programs', '/blog']; const STATIC = ['/', '/about', '/contact', '/training', '/services', '/blog', '/promo-eventi', '/privacy', '/cookie-policy'];
export const GET: APIRoute = ({ site }) => { export const GET: APIRoute = ({ site }) => {
const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, ''); const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, '');
const posts = getDb().prepare('SELECT slug FROM posts WHERE draft = 0').all() as { slug: string }[]; const posts = getDb().prepare('SELECT slug FROM posts WHERE draft = 0').all() as { slug: string }[];
const urls = [ const urls = [
...STATIC, ...STATIC,
...programs.map((p) => `/programs/${p.slug}`), ...services.map((p) => `/services/${p.slug}`),
...posts.map((p) => `/blog/${p.slug}`), ...posts.map((p) => `/blog/${p.slug}`),
]; ];
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => ` <url><loc>${base}${u}</loc></url>`).join('\n')}\n</urlset>`; const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => ` <url><loc>${base}${u}</loc></url>`).join('\n')}\n</urlset>`;
+1 -18
View File
@@ -5,9 +5,7 @@ import TrainingCards from '../components/home/TrainingCards.astro';
import Agenda from '../components/Agenda.astro'; import Agenda from '../components/Agenda.astro';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { trainings } from '../data/trainings'; import { trainings } from '../data/trainings';
import trialImg from '../assets/img/trial-class.jpg';
import T from '../components/content/T.astro'; import T from '../components/content/T.astro';
import TImg from '../components/content/TImg.astro';
import { t } from '../lib/content'; import { t } from '../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
@@ -18,7 +16,6 @@ export const prerender = false;
<section class="section" style="padding-bottom:0"> <section class="section" style="padding-bottom:0">
<div class="section-heading"> <div class="section-heading">
<T tag="training.intro.title" as="h2" />
<T tag="training.intro.subtitle" as="p" /> <T tag="training.intro.subtitle" as="p" />
</div> </div>
</section> </section>
@@ -30,7 +27,7 @@ export const prerender = false;
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}> <div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />} {imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}> <div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
<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" /> <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> <a class="btn" href={tr.cta.href}><T tag={`training.${tr.id}.cta-label`} as="span" /></a>
</div> </div>
@@ -40,25 +37,11 @@ export const prerender = false;
</section> </section>
<Agenda /> <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> </Base>
<style> <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 { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
.tblock--reverse img { order: 2; } .tblock--reverse img { order: 2; }
.tblock h2 { letter-spacing: .1em; } .tblock h2 { letter-spacing: .1em; }
.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; } } @media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
</style> </style>
+13
View File
@@ -35,3 +35,16 @@ describe('applyContentUpdate', () => {
expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 }); expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
}); });
}); });
describe('GET /api/admin/content/[tag]', () => {
it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => {
const { GET } = await import('../src/pages/api/admin/content/[tag]');
const res = await GET({ params: { tag: 'home.hero.cta' } } as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.tag).toBe('home.hero.cta');
expect(typeof body.guid).toBe('string');
expect(body.type).toBeDefined();
expect(Array.isArray(body.styleOptions)).toBe(true);
});
});
+86
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDb } from '../src/lib/db'; import { createDb } from '../src/lib/db';
import { createUser } from '../src/lib/auth';
describe('schema content_blocks e ruoli', () => { describe('schema content_blocks e ruoli', () => {
it('crea la tabella content_blocks', () => { it('crea la tabella content_blocks', () => {
@@ -24,6 +28,60 @@ describe('schema content_blocks e ruoli', () => {
expect(row.role).toBe('admin'); expect(row.role).toBe('admin');
}); });
it('migra i tag programmi → servizi preservando i valori modificati dal pannello', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-test-'));
const path = join(dir, 'test.db');
try {
// Simula un DB di produzione pre-rinomina: tag vecchi con valori modificati dal pannello.
const db1 = createDb(path);
const old = db1.transaction(() => {
db1.prepare("DELETE FROM content_blocks WHERE tag IN ('home.services.title', 'service.rehab.title', 'services.hero.title', 'services.meta.title', 'footer.services.1.label', 'footer.col.services-title')").run();
const ins = db1.prepare("INSERT INTO content_blocks (tag, type, value, updated_by) VALUES (?, 'text', ?, 'editor')");
ins.run('home.programs.title', 'Titolo modificato');
ins.run('program.rehab.title', 'Rehab modificato');
ins.run('programs.hero.title', 'Hero modificato');
ins.run('footer.programs.1.label', 'Label modificata');
ins.run('footer.col.programs-title', 'Colonna modificata');
// Riga mai toccata dal pannello: valore identico al vecchio seed → deve prendere il nuovo default.
db1.prepare("INSERT INTO content_blocks (tag, type, value) VALUES ('programs.meta.title', 'text', 'Programmi')").run();
db1.prepare("UPDATE content_blocks SET value = 'Programmi' WHERE tag = 'global.nav.3.label'").run();
});
old();
db1.close();
// Riapertura: la migrazione rinomina i tag preservando valore e updated_by.
const db2 = createDb(path);
const val = (tag: string) =>
db2.prepare('SELECT value, updated_by FROM content_blocks WHERE tag = ?').get(tag) as { value: string; updated_by: string | null } | undefined;
expect(val('home.services.title')).toMatchObject({ value: 'Titolo modificato', updated_by: 'editor' });
expect(val('service.rehab.title')).toMatchObject({ value: 'Rehab modificato', updated_by: 'editor' });
expect(val('services.hero.title')).toMatchObject({ value: 'Hero modificato', updated_by: 'editor' });
expect(val('footer.services.1.label')).toMatchObject({ value: 'Label modificata', updated_by: 'editor' });
expect(val('footer.col.services-title')).toMatchObject({ value: 'Colonna modificata', updated_by: 'editor' });
// Le righe rimaste al vecchio valore di default prendono il nuovo ("Servizi").
expect(val('services.meta.title')).toMatchObject({ value: 'Servizi' });
expect(val('global.nav.3.label')).toMatchObject({ value: 'Servizi' });
// La riga rinominata ma modificata dal pannello resta invariata (già verificata sopra):
// home.services.title = 'Titolo modificato'.
// Nessun tag vecchio residuo.
const residui = db2.prepare(
"SELECT count(*) AS c FROM content_blocks WHERE tag LIKE 'program%' OR tag LIKE 'home.programs%' OR tag LIKE 'footer.programs%' OR tag = 'footer.col.programs-title'"
).get() as { c: number };
expect(residui.c).toBe(0);
// Idempotente: una terza apertura non altera i valori.
db2.close();
const db3 = createDb(path);
const again = db3.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('home.services.title') as { value: string };
expect(again.value).toBe('Titolo modificato');
db3.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('migra un DB esistente senza colonna role', () => { it('migra un DB esistente senza colonna role', () => {
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile, // simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia) // quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
@@ -32,3 +90,31 @@ describe('schema content_blocks e ruoli', () => {
expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4); expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
}); });
}); });
describe('migrazioni ruoli/guid/autore', () => {
it('ogni content_block ha un guid non vuoto e univoco', () => {
const db = createDb(':memory:');
const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[];
expect(rows.length).toBeGreaterThan(100);
expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true);
const guids = new Set(rows.map((r) => r.guid));
expect(guids.size).toBe(rows.length);
});
it('lo statement di migrazione unifica editor in superuser', () => {
// La migrazione gira dentro createDb, ma :memory: non persiste tra due boot: qui
// verifichiamo l'invariante SQL applicata dalla migrazione (editor → superuser).
// L'end-to-end su DB pre-esistente è coperto dallo smoke di produzione (Task 15).
const db = createDb(':memory:');
db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run();
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string };
expect(row.role).toBe('superuser');
});
it('posts ha la colonna author_id', () => {
const db = createDb(':memory:');
const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
expect(cols.some((c) => c.name === 'author_id')).toBe(true);
});
});
+7 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db'; import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed'; import { contentSeed } from '../src/data/content-seed';
import { makeContentStore, syncSeed } from '../src/lib/content'; import { makeContentStore, syncSeed, resolveContent } from '../src/lib/content';
let db: Database.Database; let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); }); beforeEach(() => { db = createDb(':memory:'); });
@@ -58,4 +58,10 @@ describe('content store', () => {
store.invalidate(); store.invalidate();
expect(store.resolve(contentSeed[0].tag).classes).toBe(''); expect(store.resolve(contentSeed[0].tag).classes).toBe('');
}); });
it('resolveContent restituisce il guid dal DB', () => {
const r = resolveContent('home.hero.cta');
expect(typeof r.guid).toBe('string');
expect(r.guid.length).toBeGreaterThan(10);
});
}); });
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { canModifyPost } from '../src/lib/posts';
describe('canModifyPost', () => {
const own = { author_id: 7 };
const other = { author_id: 8 };
const orphan = { author_id: null };
it('admin e superuser modificano qualsiasi post', () => {
for (const role of ['admin', 'superuser']) {
expect(canModifyPost(role, 7, other)).toBe(true);
expect(canModifyPost(role, 7, orphan)).toBe(true);
}
});
it('user modifica solo i propri', () => {
expect(canModifyPost('user', 7, own)).toBe(true);
expect(canModifyPost('user', 7, other)).toBe(false);
expect(canModifyPost('user', 7, orphan)).toBe(false);
});
});
+26 -1
View File
@@ -3,8 +3,9 @@ import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db'; import { createDb } from '../src/lib/db';
import { import {
createPost, updatePost, deletePost, getPostById, getPublishedBySlug, createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput, listPublished, listAllPosts, listRecent, listArchiveMonths, listByAuthor, type PostInput,
} from '../src/lib/posts'; } from '../src/lib/posts';
import { createUser, getUsernameById, listUsers } from '../src/lib/auth';
let db: Database.Database; let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); }); beforeEach(() => { db = createDb(':memory:'); });
@@ -72,3 +73,27 @@ describe('posts repository', () => {
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/); expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
}); });
}); });
describe('autore articoli', () => {
it('createPost salva author_id e listByAuthor filtra', () => {
const aliceId = createUser(db, 'alice', 'segreta123', 'user');
const bobId = createUser(db, 'bob', 'segreta123', 'user');
createPost(db, { ...base, slug: 'a1', author_id: aliceId });
createPost(db, { ...base, slug: 'a2', author_id: aliceId });
createPost(db, { ...base, slug: 'b1', author_id: bobId });
expect(listByAuthor(db, aliceId).length).toBe(2);
expect(listByAuthor(db, bobId).length).toBe(1);
});
it('post senza author_id ha author_id null', () => {
const id = createPost(db, { ...base, slug: 'orfano' });
expect(getPostById(db, id)!.author_id).toBeNull();
});
it('getUsernameById e listUsers', () => {
const id = createUser(db, 'carla', 'segreta123', 'superuser');
expect(getUsernameById(db, id)).toBe('carla');
expect(getUsernameById(db, 99999)).toBeNull();
expect(listUsers(db).some((u) => u.username === 'carla' && u.role === 'superuser')).toBe(true);
});
});
+47 -21
View File
@@ -1,37 +1,63 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db'; import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth'; import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth';
let db: Database.Database; let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); }); beforeEach(() => { db = createDb(':memory:'); });
describe('ruoli', () => { describe('ruoli', () => {
it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => { it('createUser accetta i tre ruoli e getSessionUser li ritorna', () => {
createUser(db, 'cliente', 'segreta123', 'editor'); createUser(db, 'u', 'segreta123', 'user');
const token = login(db, 'cliente', 'segreta123')!; createUser(db, 's', 'segreta123', 'superuser');
expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' }); expect(getSessionUser(db, login(db, 'u', 'segreta123')!)).toMatchObject({ role: 'user' });
expect(getSessionUser(db, login(db, 's', 'segreta123')!)).toMatchObject({ role: 'superuser' });
}); });
it('createUser senza ruolo crea admin', () => { it('createUser senza ruolo crea admin', () => {
createUser(db, 'adriano', 'segreta123'); createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!; expect(getSessionUser(db, login(db, 'adriano', 'segreta123')!)).toMatchObject({ role: 'admin' });
expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' }); });
}); });
it('admin accede a tutto, editor solo a contenuti e logout', () => { describe('canAccessAdminPath', () => {
expect(canAccessAdminPath('admin', '/admin')).toBe(true); it('admin accede a tutto', () => {
expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true); for (const p of ['/admin', '/admin/users', '/admin/content', '/api/admin/posts', '/api/admin/users/1'])
expect(canAccessAdminPath('editor', '/admin/content')).toBe(true); expect(canAccessAdminPath('admin', p)).toBe(true);
expect(canAccessAdminPath('editor', '/api/admin/content/home.hero.title')).toBe(true); });
expect(canAccessAdminPath('editor', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('editor', '/admin')).toBe(false); it('superuser: contenuti + blog sì, utenti no', () => {
expect(canAccessAdminPath('editor', '/admin/new')).toBe(false); expect(canAccessAdminPath('superuser', '/admin/content')).toBe(true);
expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false); expect(canAccessAdminPath('superuser', '/admin/content/')).toBe(true);
expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false); expect(canAccessAdminPath('superuser', '/api/admin/content/home.hero.title')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/contentious')).toBe(false); expect(canAccessAdminPath('superuser', '/admin')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/content-x')).toBe(false); expect(canAccessAdminPath('superuser', '/admin/new')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/content')).toBe(true); expect(canAccessAdminPath('superuser', '/api/admin/posts')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/content/')).toBe(true); expect(canAccessAdminPath('superuser', '/admin/users')).toBe(false);
expect(canAccessAdminPath('superuser', '/api/admin/users/1')).toBe(false);
});
it('user: blog sì, contenuti e utenti no', () => {
expect(canAccessAdminPath('user', '/admin')).toBe(true);
expect(canAccessAdminPath('user', '/admin/new')).toBe(true);
expect(canAccessAdminPath('user', '/api/admin/posts')).toBe(true);
expect(canAccessAdminPath('user', '/api/admin/upload')).toBe(true);
expect(canAccessAdminPath('user', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('user', '/admin/content')).toBe(false);
expect(canAccessAdminPath('user', '/api/admin/content/x')).toBe(false);
expect(canAccessAdminPath('user', '/admin/users')).toBe(false);
});
it('la matrice non confonde prefissi simili', () => {
expect(canAccessAdminPath('user', '/admin/contentious')).toBe(true); // NON è /admin/content
expect(canAccessAdminPath('superuser', '/admin/users-x')).toBe(true); // NON è /admin/users
});
});
describe('landingFor', () => {
it('instrada ogni ruolo alla sua home', () => {
expect(landingFor('superuser')).toBe('/admin/content');
expect(landingFor('user')).toBe('/admin');
expect(landingFor('admin')).toBe('/admin');
}); });
}); });
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { safeNext } from '../src/lib/safe-next';
describe('safeNext', () => {
it('accetta path interni', () => {
expect(safeNext('/about')).toBe('/about');
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
});
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
expect(safeNext(null)).toBe('/');
expect(safeNext('')).toBe('/');
expect(safeNext('//evil.com')).toBe('/');
expect(safeNext('https://evil.com')).toBe('/');
expect(safeNext('/api/admin/posts')).toBe('/');
expect(safeNext('javascript:alert(1)')).toBe('/');
expect(safeNext('not-a-path')).toBe('/');
expect(safeNext('/\\evil.com')).toBe('/');
expect(safeNext('/\\/evil.com')).toBe('/');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import {
createUser, countAdmins, getUserById, updateUserRole,
updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword,
} from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('gestione utenti', () => {
it('countAdmins conta i soli admin', () => {
createUser(db, 'a1', 'segreta123', 'admin');
createUser(db, 's1', 'segreta123', 'superuser');
expect(countAdmins(db)).toBe(1);
});
it('updateUserRole e getUserById', () => {
const id = createUser(db, 'u1', 'segreta123', 'user');
updateUserRole(db, id, 'superuser');
expect(getUserById(db, id)!.role).toBe('superuser');
});
it('updateUserPassword cambia lhash', () => {
const id = createUser(db, 'u2', 'vecchia123', 'user');
updateUserPassword(db, id, hashPassword('nuova12345'));
const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h;
expect(verifyPassword('nuova12345', hash)).toBe(true);
});
it('deleteUser rimuove', () => {
const id = createUser(db, 'u3', 'segreta123', 'user');
deleteUser(db, id);
expect(getUserById(db, id)).toBeNull();
});
it('randomPassword genera almeno 12 caratteri', () => {
expect(randomPassword().length).toBeGreaterThanOrEqual(12);
});
it('isRole valida i ruoli', () => {
expect(isRole('admin')).toBe(true);
expect(isRole('superuser')).toBe(true);
expect(isRole('user')).toBe(true);
expect(isRole('editor')).toBe(false);
expect(isRole(3)).toBe(false);
});
});