From 1aa82f533204c6ccdf9c519b228855f14ddf2e6e Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 12 Jul 2026 15:50:21 +0200 Subject: [PATCH] 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) --- README.md | 197 +- .../plans/2026-07-01-insanitylab-website.md | 3513 ----------------- ...07-05-contenuti-taggati-pannello-editor.md | 1082 ----- .../plans/2026-07-05-inventario-contenuti.md | 101 - .../2026-07-05-utenti-ruoli-login-inline.md | 1822 --------- .../2026-07-01-insanitylab-website-design.md | 130 - .../2026-07-02-animazioni-template-design.md | 61 - ...ontenuti-taggati-pannello-editor-design.md | 146 - .../2026-07-05-utenti-ruoli-login-design.md | 210 - 9 files changed, 103 insertions(+), 7159 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-01-insanitylab-website.md delete mode 100644 docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md delete mode 100644 docs/superpowers/plans/2026-07-05-inventario-contenuti.md delete mode 100644 docs/superpowers/plans/2026-07-05-utenti-ruoli-login-inline.md delete mode 100644 docs/superpowers/specs/2026-07-01-insanitylab-website-design.md delete mode 100644 docs/superpowers/specs/2026-07-02-animazioni-template-design.md delete mode 100644 docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md delete mode 100644 docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md diff --git a/README.md b/README.md index 5560219..39a46a9 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,154 @@ # InsanityLab Website -Sito vetrina per InsanityLab con blog integrato e form di contatti. +Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**. ## Descrizione -Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 7** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages. +Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor rich‑text, e il form di contatti invia email tramite SMTP. -## Stack Tecnologico +## Stack tecnologico -- **Framework**: Astro 7 -- **Runtime**: Node.js (adapter standalone) -- **Database**: SQLite (better-sqlite3) -- **Autenticazione**: bcryptjs -- **Email**: Nodemailer -- **Markup Editor**: TipTap (rich text) -- **Font System**: @fontsource (Montserrat, Open Sans) -- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati) -- **Testing**: Vitest +- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche) +- **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone) +- **Database**: SQLite via `better-sqlite3` +- **Autenticazione**: sessioni con password hashate (`bcryptjs`) +- **Email**: Nodemailer (SMTP) +- **Editor articoli**: TipTap (rich text) +- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno +- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati) +- **Test**: Vitest - **Linguaggio**: TypeScript +- **Deploy**: Docker (multi‑stage, `node:22-slim`) + Traefik su VPS -## Installazione +## Funzionalità principali + +- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi". +- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina. +- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito. +- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo. +- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`). +- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP. + +## Installazione (sviluppo) ### Prerequisiti - - Node.js 18+ e npm ### Setup - ```bash -# Installa dipendenze npm install - -# Copia il template di configurazione env -cp .env.example .env - -# Crea il primo utente amministratore (dopo aver configurato il DB) -npm run create-user -- admin tuapassword +cp .env.example .env # poi compila i valori reali +npm run create-user -- admin admin +npm run dev # http://localhost:4321 ``` -Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente. +Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato automaticamente con i contenuti di default (seed) e le migrazioni idempotenti. -## Comandi Disponibili +## Comandi | Comando | Descrizione | |---------|-------------| -| `npm run dev` | Avvia il server di sviluppo (localhost:4321) | -| `npm run build` | Compila il progetto per production | -| `npm run preview` | Visualizza l'anteprima della build (locale) | -| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) | -| `npm run test` | Esegui i test con Vitest | -| `npm run create-user` | Crea un nuovo utente: `-- [admin\|editor]` | +| `npm run dev` | Server di sviluppo (localhost:4321) | +| `npm run build` | Build di produzione | +| `npm run preview` | Anteprima locale della build | +| `npm run start` | Server di produzione (`node --env-file=.env`) | +| `npm run test` | Test con Vitest | +| `npm run create-user` | Crea/aggiorna utente: `-- [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** `` e ``: 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 -- 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 ``/`` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`). + +## Ruoli e accessi + +Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`: + +| Ruolo | Può fare | +|-------|----------| +| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) | +| `superuser` | Pannello contenuti + blog | +| `user` | Solo i **propri** articoli del blog | + +Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin. + +## Struttura del progetto ``` insanitylab-website/ ├── src/ -│ ├── pages/ # Pagine Astro (routing automatico) -│ ├── components/ # Componenti Astro riutilizzabili -│ ├── layouts/ # Layout master -│ ├── utils/ # Funzioni di utilità -│ ├── env.d.ts # Type definitions globali -│ └── ... -├── data/ # Database SQLite e dati persistenti -│ └── insanitylab.db # (generato al primo avvio) -├── uploads/ # Directory per upload immagini e file -├── dist/ # Output build (server) -├── tests/ # Test suite -├── astro.config.mjs # Configurazione Astro -├── tsconfig.json # Configurazione TypeScript -├── vitest.config.ts # Configurazione test framework -├── .env.example # Template variabili di ambiente -├── .env # Variabili di ambiente (non versionato) -├── package.json # Dipendenze e script npm -└── README.md # Questo file +│ ├── pages/ # Route Astro (vetrina, /admin, /api) +│ ├── components/ # Componenti (home/, content/, Header, Footer, …) +│ ├── layouts/ # Layout Base +│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed) +│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env +│ ├── assets/img/ # Immagini ottimizzate da Astro +│ └── styles/ # global.css +├── data/ # SQLite (volume in produzione) +├── uploads/ # Upload immagini (volume in produzione) +├── scripts/ # create-user, smoke test +├── astro.config.mjs # Config Astro (site, allowedDomains, redirects) +├── compose.yaml # Stack Docker + label Traefik +├── Dockerfile # Build multi-stage +├── .env.example # Template variabili d'ambiente +└── README.md ``` -## Configurazione Ambiente +## Variabili d'ambiente -Il file `.env` richiede le seguenti variabili: +Il file `.env` (non versionato) richiede: -- `SMTP_HOST`: Host SMTP per invio email -- `SMTP_PORT`: Porta SMTP (solitamente 587) -- `SMTP_USER`: Utente SMTP -- `SMTP_PASS`: Password SMTP -- `CONTACT_TO`: Email di destinazione contatti -- `CONTACT_FROM`: Email mittente -- `DB_PATH`: Percorso database SQLite -- `UPLOADS_DIR`: Cartella upload file +| Variabile | Descrizione | +|-----------|-------------| +| `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) | +| `SMTP_PORT` | Porta SMTP (`465` con SSL) | +| `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) | +| `SMTP_PASS` | Password della casella | +| `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) | +| `CONTACT_TO` | Destinatario dei messaggi del form | +| `DB_PATH` | Percorso del database SQLite | +| `UPLOADS_DIR` | Cartella degli upload | -## Development Workflow +La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`. -1. **Avvia il server locale**: `npm run dev` -2. **Modifica file** in `src/` (hot reload automatico) -3. **Test**: `npm run test` -4. **Build**: `npm run build` (verifica errori di build) -5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione) +## Deploy in produzione -## Deployment +Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/insanitylab/` (repo in `src/`, volumi `data/` e `uploads/`). Aggiornamento: ```bash -# Build per production -npm run build - -# Opzionalmente, preview locale -npm run preview - -# In production, il server parte con: -npm run start - -# Variabili ambiente in production vanno impostate sull'hosting -# (es. Hostinger VPS, Vercel, etc.) +cd /opt/docker/insanitylab/src +git pull +docker compose up -d --build ``` -## Riferimenti Tecnici +Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello. -Per dettagli su architettura, schema database, componenti e specifiche vedi: -- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md` -- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md` +### Dominio e DNS -## Problemi Comuni +- Dominio ufficiale: **insanitylab.it** (canonico l'apex; `www` fa redirect 301 all'apex tramite Traefik). +- I record **A** di `@` e `www` puntano al VPS; la **posta resta su Aruba** (MX, SPF, webmail non vengono toccati). +- TLS Let's Encrypt automatico via Traefik (certresolver `mytlschallenge`). -### `better-sqlite3` fallisce su npm install +> Attenzione dietro Traefik: `allowedDomains` in `astro.config.mjs` deve elencare i domini serviti, altrimenti Astro ignora `X-Forwarded-Proto/Host` e il controllo CSRF respinge i POST con 403. -Se `better-sqlite3` non compila, assicurati di avere build tools installati: -- **Linux**: `sudo apt-get install build-essential python3` -- **macOS**: Xcode Command Line Tools (`xcode-select --install`) -- **Windows**: Visual Studio Build Tools o MinGW +## Problemi comuni -### Database locked +**`better-sqlite3` non compila su `npm install`** — servono i build tools: +- Linux: `sudo apt-get install build-essential python3` +- macOS: `xcode-select --install` +- Windows: Visual Studio Build Tools -Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`. +**Modifiche allo ` - - -``` - -- [ ] **Step 3: ContactForm.astro** - -```astro ---- -interface Props { variant?: 'full' | 'footer' } -const { variant = 'full' } = Astro.props; ---- -
- - {variant === 'full' && } - {variant === 'full' && } - - - - - -
- - -``` - -- [ ] **Step 4: Footer.astro** - -Quattro colonne come da Figma (Homepage.pdf, fondo `--c-dark`): brand (logo light, tagline "PERFORMANCE. BALANCE. LONGEVITY.", testo breve, telefono, indirizzo, orari), colonna TRAINING (One to one, Small groups, Performance class, Fit remote → `/training`), colonna PROGRAMMI (le 6 voci → `/programs/`), colonna CONTACT con ``. Barra finale: `© {new Date().getFullYear()} Insanitylab, All Rights Reserved` + "Seguici" con link social da `site.socials`. - -```astro ---- -import { site } from '../data/site'; -import ContactForm from './ContactForm.astro'; -const year = new Date().getFullYear(); ---- -
-
-
- InsanityLab -

PERFORMANCE.  BALANCE.  LONGEVITY.

-

Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.

-

{site.phone}
{site.address}, {site.cityLine}

- {site.hours.map((h) =>

{h}

)} -
- - -
-

Contact

- -
-
-
-
-

© {year} Insanitylab, All Rights Reserved

-

Seguici {site.socials.map((s) => {s.label})}

-
-
-
- - -``` - -- [ ] **Step 5: 404.astro** - -```astro ---- -import Base from '../layouts/Base.astro'; -export const prerender = true; ---- - -
-
-

Errore 404

-

Pagina non trovata

-

La pagina che cerchi non esiste o è stata spostata.

- Torna alla home -
-
- -``` - -- [ ] **Step 6: aggiorna index placeholder per usare Base** - -```astro ---- -import Base from '../layouts/Base.astro'; -export const prerender = true; ---- - -

Homepage in costruzione

- -``` - -- [ ] **Step 7: verifica visiva e build** - -Run: `npm run dev` e apri `http://localhost:4321/` — header sticky con nav, footer scuro 4 colonne, pagina 404 su URL inesistente. Poi `npm run build`. -Expected: nessun errore; nav mobile funzionante sotto 992px. - -- [ ] **Step 8: commit** - -```bash -git add -A && git commit -m "feat: layout base con header, footer e 404" -``` - ---- - -### Task 8: Dati statici dei contenuti - -**Files:** -- Create: `src/data/site.ts`, `src/data/programs.ts`, `src/data/trainings.ts`, `src/data/agenda.ts`, `src/data/team.ts`, `src/data/method.ts`, `src/data/pricing.ts`, `src/data/partners.ts` - -**Interfaces:** -- Produces: export nominati usati dalle pagine. Testi reali dai PDF Figma (riportati sotto). Nessun lorem ipsum salvo dove il Figma stesso è placeholder (agenda, che riusa i nomi del template). - -- [ ] **Step 1: site.ts** - -```ts -export const site = { - name: 'InsanityLab', - phone: '351 7535977', - email: 'info@insanitylab.it', - address: 'Via Leandro Alberti, 76', - cityLine: '40139 Bologna', - hours: ['Lun – Ven 6.00 – 21.00', 'Sab 8.00 – 19.00 · Dom chiuso'], - socials: [ - { label: 'Instagram', url: 'https://www.instagram.com/insanitylab' }, - { label: 'Facebook', url: 'https://www.facebook.com/insanitylab' }, - { label: 'LinkedIn', url: 'https://www.linkedin.com/company/insanitylab' }, - ], - navigation: [ - { label: 'Insanitylab', href: '/about' }, - { label: 'Training', href: '/training' }, - { label: 'Programmi', href: '/programs' }, - { label: 'Blog & Edugo', href: '/blog' }, - { label: 'Contatti', href: '/contact' }, - ], - footerTraining: [ - { label: 'One to one', href: '/training#one-to-one' }, - { label: 'Small groups', href: '/training#small-groups' }, - { label: 'Performance class', href: '/training#performance-class' }, - { label: 'Fit remote', href: '/training#fit-remote' }, - ], - footerPrograms: [ - { label: 'Personal Training', href: '/programs/personal-training' }, - { label: 'Performance Class', href: '/programs/performance-class' }, - { label: 'Coaching', href: '/programs/coaching' }, - { label: 'Rehab', href: '/programs/rehab' }, - { label: 'Pilates Flow', href: '/programs/pilates-flow' }, - { label: 'Nutrizione', href: '/programs/nutrition' }, - ], -}; -``` - -- [ ] **Step 2: programs.ts** - -Excerpt = testi reali di Programmi.pdf. `longDescription` e `faq`: per `performance-class` dai testi di Performance class.pdf; per gli altri derivati dall'excerpt e da Training.pdf (il cliente li integrerà — testo comunque sensato, niente lorem). - -```ts -export interface Program { - slug: string; - title: string; - subtitle: string; - excerpt: string; - longDescription: string; - features: string[]; - faq: { q: string; a: string }[]; - hasPricing: boolean; -} - -export const programs: Program[] = [ - { - slug: 'personal-training', - title: 'Personal Training', - subtitle: 'One to One / Small Group', - excerpt: 'Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.', - longDescription: 'Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.', - features: ['Trainer dedicato', 'Percorso su misura', 'Valutazione iniziale e monitoraggio', 'Formula one to one o small group (max 3)'], - faq: [ - { q: 'Come si svolge la prima seduta?', a: 'Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto.' }, - { q: 'Con quale frequenza ci si allena?', a: 'La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza.' }, - { q: 'È adatto a chi parte da zero?', a: 'Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante.' }, - ], - hasPricing: false, - }, - { - slug: 'performance-class', - title: 'Performance Class', - subtitle: 'Gruppo max 8 persone', - excerpt: 'La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.', - longDescription: 'La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.', - features: ['Allenamento ibrido', 'Forza, ipertrofia, condizionamento metabolico e lavoro funzionale', 'Sessioni da 50 minuti', 'Fasi di bassa, media e alta intensità', 'Programmazione mensile'], - faq: [ - { q: 'Che cosa si intende per Performance Class?', a: 'Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio.' }, - { q: 'Che tipo di allenamento viene svolto?', a: 'Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile.' }, - { q: 'A chi è consigliato questo servizio?', a: 'A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile.' }, - { q: 'Qual è la differenza rispetto a una class tradizionale?', a: 'La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati.' }, - { q: 'Che tipo di risultati o miglioramenti si ottengono?', a: 'Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo.' }, - { q: 'Qual è l’intensità del lavoro?', a: 'Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti.' }, - ], - hasPricing: true, - }, - { - slug: 'coaching', - title: 'Coaching', - subtitle: 'Programma di allenamento e monitoraggio', - excerpt: 'Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.', - longDescription: 'Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).', - features: ['Programma su misura', 'Supporto e monitoraggio costanti', 'Ovunque ti alleni (Fit Remote)', 'Sviluppo di autonomia'], - faq: [ - { q: 'Come funziona il monitoraggio?', a: 'Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati.' }, - { q: 'Serve attrezzatura?', a: 'Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa.' }, - { q: 'È adatto se viaggio spesso?', a: 'Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi.' }, - ], - hasPricing: false, - }, - { - slug: 'pilates-flow', - title: 'Pilates Flow', - subtitle: 'Movimento e controllo', - excerpt: 'Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre l’osteopatia supporta recupero, prevenzione e benessere globale integrandosi con l’allenamento.', - longDescription: 'Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con l’osteopatia, supporta recupero, prevenzione e benessere globale all’interno del percorso di allenamento.', - features: ['Postura e core', 'Movimento fluido e controllo', 'Integrazione con osteopatia', 'Benessere globale'], - faq: [ - { q: 'A chi è adatto il Pilates Flow?', a: 'A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello.' }, - { q: 'Serve esperienza precedente?', a: 'No: le progressioni sono graduali e seguite da professionisti qualificati.' }, - { q: 'Come si integra con l’osteopatia?', a: 'Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento.' }, - ], - hasPricing: false, - }, - { - slug: 'rehab', - title: 'Rehab', - subtitle: 'Recupero post-infortunio, patologie muscolo-scheletriche', - excerpt: 'Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno all’attività fisica in modo sicuro, progressivo e personalizzato.', - longDescription: 'Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno all’attività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.', - features: ['Recupero post-infortunio', 'Patologie muscolo-scheletriche', 'Supervisione di chinesiologi e osteopati', 'Progressione sicura e personalizzata'], - faq: [ - { q: 'Chi segue il percorso Rehab?', a: 'Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con l’area osteopatica.' }, - { q: 'Serve una diagnosi medica?', a: 'Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari.' }, - { q: 'Quanto dura il percorso?', a: 'Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso.' }, - ], - hasPricing: false, - }, - { - slug: 'nutrition', - title: 'Nutrizione', - subtitle: 'Alimentazione e stile di vita', - excerpt: 'La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.', - longDescription: 'Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.', - features: ['Piani alimentari su misura', 'Integrazione con l’allenamento', 'Sostenibilità nel tempo', 'Focus su energia e performance'], - faq: [ - { q: 'Come inizia il percorso di nutrizione?', a: 'Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato.' }, - { q: 'È integrato con il training?', a: 'Sì: alimentazione e allenamento vengono programmati in modo coerente.' }, - { q: 'Sono previsti controlli periodici?', a: 'Sì, con aggiornamento del piano in base ai risultati.' }, - ], - hasPricing: false, - }, -]; - -export function getProgram(slug: string): Program | undefined { - return programs.find((p) => p.slug === slug); -} -``` - -- [ ] **Step 3: trainings.ts (4 card Training)** - -```ts -export const trainings = [ - { id: 'one-to-one', title: 'One to One', caption: 'Allenamento con un trainer dedicato', image: 'training-one-to-one', description: 'Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.', cta: { label: 'Contattaci', href: '/contact' } }, - { id: 'small-groups', title: 'Small Groups', caption: 'Mini gruppi (max 3 persone) con trainer dedicato', image: 'training-small-groups', description: 'Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.', cta: { label: 'Contattaci', href: '/contact' } }, - { id: 'performance-class', title: 'Performance Class', caption: 'Classi fino a 8 persone con trainer certificato', image: 'training-performance-class', description: 'Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.', cta: { label: 'Scopri', href: '/programs/performance-class' } }, - { id: 'fit-remote', title: 'Fit Remote', caption: 'Piano di allenamento su misura, ovunque ti alleni.', image: 'training-fit-remote', description: 'Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.', cta: { label: 'Contattaci', href: '/contact' } }, -] as const; -``` - -- [ ] **Step 4: agenda.ts (orario placeholder dal template, come nel Figma)** - -```ts -export const disciplines = ['Events', 'Crossfit', 'Cardio', 'Box', 'Meditation', 'Yoga Classes', 'Body Balance'] as const; -export const days = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'] as const; -export const slots = ['10.00', '12.00', '16.00', '18.00', '20.00'] as const; - -export interface AgendaEntry { day: number; slot: string; discipline: string; time: string; trainer: string } - -// Placeholder dal template Prowess (come nel Figma); il cliente fornirà l'orario reale. -export const agenda: AgendaEntry[] = [ - { day: 0, slot: '10.00', discipline: 'Crossfit', time: '10.00 - 11.00', trainer: 'Amanda Snow' }, - { day: 0, slot: '12.00', discipline: 'Box', time: '12.00 - 13.00', trainer: 'Matthew Dunn' }, - { day: 0, slot: '18.00', discipline: 'Cardio', time: '18.00 - 19.00', trainer: 'Randy Nelson' }, - { day: 0, slot: '20.00', discipline: 'Crossfit', time: '20.00 - 21.00', trainer: 'Amanda Snow' }, - { day: 1, slot: '10.00', discipline: 'Cardio', time: '10.00 - 11.00', trainer: 'Randy Nelson' }, - { day: 1, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' }, - { day: 1, slot: '16.00', discipline: 'Body Balance', time: '16.00 - 17.00', trainer: 'Barbara Holland' }, - { day: 1, slot: '20.00', discipline: 'Box', time: '20.00 - 21.00', trainer: 'Matthew Dunn' }, - { day: 2, slot: '10.00', discipline: 'Crossfit', time: '10.00 - 11.00', trainer: 'Amanda Snow' }, - { day: 2, slot: '12.00', discipline: 'Meditation', time: '12.00 - 13.00', trainer: 'Nicole Fuller' }, - { day: 2, slot: '16.00', discipline: 'Box', time: '16.00 - 17.00', trainer: 'Matthew Dunn' }, - { day: 2, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Barbara Holland' }, - { day: 3, slot: '10.00', discipline: 'Body Balance', time: '10.00 - 11.00', trainer: 'Barbara Holland' }, - { day: 3, slot: '12.00', discipline: 'Box', time: '12.00 - 13.00', trainer: 'Matthew Dunn' }, - { day: 3, slot: '16.00', discipline: 'Crossfit', time: '16.00 - 17.00', trainer: 'Amanda Snow' }, - { day: 3, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Randy Nelson' }, - { day: 4, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' }, - { day: 4, slot: '16.00', discipline: 'Body Balance', time: '16.00 - 17.00', trainer: 'Barbara Holland' }, - { day: 4, slot: '18.00', discipline: 'Cardio', time: '18.00 - 19.00', trainer: 'Randy Nelson' }, - { day: 4, slot: '20.00', discipline: 'Yoga Classes', time: '20.00 - 21.00', trainer: 'Amanda Snow' }, - { day: 5, slot: '10.00', discipline: 'Cardio', time: '10.00 - 11.00', trainer: 'Randy Nelson' }, - { day: 5, slot: '12.00', discipline: 'Body Balance', time: '12.00 - 13.00', trainer: 'Barbara Holland' }, - { day: 5, slot: '18.00', discipline: 'Box', time: '18.00 - 19.00', trainer: 'Matthew Dunn' }, - { day: 6, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' }, - { day: 6, slot: '16.00', discipline: 'Cardio', time: '16.00 - 17.00', trainer: 'Randy Nelson' }, - { day: 6, slot: '18.00', discipline: 'Box', time: '18.00 - 19.00', trainer: 'Matthew Dunn' }, - { day: 6, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Barbara Holland' }, -]; -``` - -- [ ] **Step 5: team.ts** - -```ts -export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; founder: boolean } - -export const team: Member[] = [ - { name: 'Donata Dal Pastro', shortName: 'Donata', role: 'Chinesiologa e Osteopata', founder: true, image: 'team-donata', - bio: 'Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona l’area dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza.' }, - { name: 'Nicola Antonelli', shortName: 'Nicola', role: 'Chinesiologo e Osteopata', founder: true, image: 'team-nicola', - bio: 'Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e all’allenamento avanzato.' }, - { name: 'Eva Cassetta', shortName: 'Eva', role: 'Business Manager – Balance & Strategy', founder: true, - bio: 'Eva Cassetta ricopre il ruolo di Business Manager – Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo.' }, - { name: 'Alessandro Lazzari', shortName: 'Alessandro', role: 'Trainer – Functional & Movement', founder: false, - bio: 'Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera.' }, - { name: 'Eleonora Cassani', shortName: 'Eleonora', role: 'Trainer – Planning Manager', founder: false, image: 'team-eleonora', - bio: 'Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza.' }, - { name: "Antonello D'Alessandro", shortName: 'Antonello', role: 'Trainer – Exercise & Health', founder: false, image: 'team-antonello', - bio: 'Trainer – Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia.' }, - { name: 'Alice Roda', shortName: 'Alice', role: 'Trainer – Functional & Movement', founder: false, - bio: 'Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso.' }, - { name: 'Davide Bisagni', shortName: 'Davide', role: 'Trainer – Functional & Movement', founder: false, - bio: 'Trainer – Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature.' }, - { name: 'Giulia Barbàra', shortName: 'Giulia', role: 'Trainer – Pilates, Yoga & Mindfulness', founder: false, - bio: 'Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo.' }, -]; - -export const homeTeam = ['Donata', 'Nicola', 'Eleonora', 'Antonello']; -``` - -- [ ] **Step 6: method.ts, pricing.ts, partners.ts** - -`src/data/method.ts`: -```ts -export const methodIntro = 'Il Metodo Insanitylab si basa su un percorso strutturato in fasi, dove le prime due rappresentano il focus standard del lavoro: valutazione iniziale e avvio dell’allenamento personalizzato. A queste possono essere aggiunti servizi e approfondimenti extra, in base alle esigenze e agli obiettivi della persona.'; -export const methodExtraNote = 'Disponibili su richiesta, non incluse nel percorso standard.'; - -export const methodSteps = [ - { n: 1, title: 'Prima consulenza conoscitiva iniziale', text: 'Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', extra: false, image: 'method-step-1' }, - { n: 2, title: 'Prima seduta operativa', text: 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', extra: false, image: 'method-step-2' }, - { n: 3, title: 'Plicometria', text: 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', extra: true, image: 'method-step-3' }, - { n: 4, title: 'Analisi antropometriche', text: 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', extra: true, image: 'method-step-4' }, -]; -``` - -`src/data/pricing.ts` (prezzi da Performance class.pdf): -```ts -export const performanceClassPricing = { - heading: 'Acquista la tua performance class', - intro: '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.', - groups: [ - { - label: 'Programmi performance class semestrale', - open: true, - plans: [ - { monthly: 130, entries: 1, note: 'tot: 780€ · da 23€ lezione singola', installments: '2 rate da 390€ – inizio 60', best: 'best price 720€' }, - { monthly: 234, entries: 2, note: 'tot: 1.400€ · da 23€ lezione singola', installments: '2 rate da 700€ – inizio 60', best: 'best price 1.250€' }, - { monthly: 300, entries: 3, note: 'tot: 1.800€ · da 23€ lezione singola', installments: '2 rate da 900€ – inizio 60', best: 'best price 1.650€' }, - ], - }, - { label: 'Programmi performance class trimestrale', open: false, plans: [] }, - { label: 'Programmi performance class mensile', open: false, plans: [] }, - ], -}; -``` -(I gruppi senza piani mostrano "Contattaci per i dettagli" — nel Figma sono accordion chiusi senza contenuto visibile.) - -`src/data/partners.ts`: -```ts -export const partners = [ - { name: 'Ticket Welfare Edenred', image: 'partner-edenred' }, - { name: 'Welbee', image: 'partner-welbee' }, - { name: 'AON', image: 'partner-aon' }, - { name: 'Howden', image: 'partner-howden' }, -]; -``` - -- [ ] **Step 7: verifica typecheck e commit** - -Run: `npx astro check 2>&1 | tail -5` (o `npx tsc --noEmit`) -Expected: nessun errore sui file dati. - -```bash -git add src/data && git commit -m "feat: dati statici contenuti da design Figma" -``` - ---- - -### Task 9: Homepage - -**Files:** -- Create: `src/components/home/HeroSlider.astro`, `src/components/home/InfoColumns.astro`, `src/components/home/FeatureBlocks.astro`, `src/components/home/QuoteBanner.astro`, `src/components/home/TrainingCards.astro`, `src/components/home/ProgramsGrid.astro`, `src/components/Agenda.astro`, `src/components/home/TeamSection.astro`, `src/components/home/MethodSteps.astro`, `src/components/home/PartnersStrip.astro` -- Modify: `src/pages/index.astro` - -**Interfaces:** -- Consumes: dati Task 8, immagini Task 2 (`src/assets/img/*` via `astro:assets`), utility CSS Task 3. -- Produces: `` riusato dal Task 11; `` riusato dal Task 11. - -Riferimento visivo: `../FigmaDoc/Homepage.pdf`. Per confronto genera le fette: `pdftoppm -png -r 50 ../FigmaDoc/Homepage.pdf /tmp/ilab-home && convert /tmp/ilab-home-1.png -crop x1800 +repage /tmp/ilab-home-slice_%d.png`. - -- [ ] **Step 1: HeroSlider.astro** - -Hero fullscreen: titolo `PERFORMANCE.` / `BALANCE.` / `LONGEVITY.` su righe, bottone SCOPRI → `/programs`, immagine hero a destra, pattern di linee orizzontali a sinistra, frecce navigazione. Il Figma mostra una sola slide compiuta: implementare struttura a slide con array (1 slide ora, estendibile). - -```astro ---- -import { Image } from 'astro:assets'; -import hero1 from '../../assets/img/hero-1.jpg'; -const slides = [ - { title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' }, -]; ---- -
- {slides.map((s, i) => ( -
-
-
-

{s.title.map((line) => {line})}

- Scopri -
- {s.alt} -
-
- ))} - {slides.length > 1 && ( - <> - - - - )} -
- - - - -``` - -- [ ] **Step 2: InfoColumns.astro** - -Tre colonne full-width con foto di sfondo e overlay scuro, titoli `PERFORMANCE.` `BALANCE.` `LONGEVITY.`; ogni colonna linka `/programs`. - -```astro ---- -import infoPerformance from '../../assets/img/info-performance.jpg'; -import infoBalance from '../../assets/img/info-balance.jpg'; -import infoLongevity from '../../assets/img/info-longevity.jpg'; -const cols = [ - { title: 'PERFORMANCE.', image: infoPerformance }, - { title: 'BALANCE.', image: infoBalance }, - { title: 'LONGEVITY.', image: infoLongevity }, -]; ---- -
- {cols.map((c) => ( - - {c.title} - - ))} -
- - -``` - -- [ ] **Step 3: FeatureBlocks.astro** - -Tre blocchi alternati testo/immagine (testi reali da Homepage.pdf): - -```astro ---- -import { Image } from 'astro:assets'; -import featPerformance from '../../assets/img/feature-performance.jpg'; -import featBalance from '../../assets/img/feature-balance.jpg'; -import featLongevity from '../../assets/img/feature-longevity.jpg'; -const blocks = [ - { title: 'PERFORMANCE', image: featPerformance, reverse: false, - text: 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.' }, - { title: 'BALANCE', image: featBalance, reverse: true, - text: 'L’equilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.' }, - { title: 'LONGEVITY', image: featLongevity, reverse: false, - text: 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.' }, -]; ---- -
-
- {blocks.map((b) => ( -
- {b.title} -
-

{b.title}

-

{b.text}

- Scopri -
-
- ))} -
-
- - -``` - -- [ ] **Step 4: QuoteBanner.astro** - -Banner scuro con foto di sfondo e citazione (nel Figma non è marquee ma banner statico con virgolette): - -```astro ---- -import quoteBg from '../../assets/img/quote-bg.jpg'; ---- -
-
-

-
-

PERFORMANCE È SPINGERE OLTRE I PROPRI LIMITI, BALANCE È SAPERLI ASCOLTARE, LONGEVITY È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.

- Insanitylab -
-
-
- - -``` - -- [ ] **Step 5: TrainingCards.astro** - -Griglia 2×4 in alternanza card testo (scura/tan) e card foto, dati da `trainings.ts` (riusato in `/training`): - -```astro ---- -import { Image } from 'astro:assets'; -import { trainings } from '../../data/trainings'; -const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true }); -const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; -const tones = ['dark', 'tan', 'dark', 'tan']; ---- -
- {trainings.map((t, i) => ( - <> -
-

{t.title}

-

{t.caption}

- Scopri → -
- {imgOf(t.image) && {t.title}} - - ))} -
- - -``` -(Verifica l'alternanza card/foto contro il PDF: riga 1 = card, foto, card, foto; riga 2 = foto, card, foto, card. Correggi con `order` finché combacia.) - -- [ ] **Step 6: ProgramsGrid.astro** - -Intestazione "PROGRAMMI" + griglia 3×2 di voci con icona linea, titolo e sottotitolo, link alla pagina dettaglio: - -```astro ---- -import { programs } from '../../data/programs'; -const icons: Record = { - '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', -}; ---- -
-
-
-

Programmi

-

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.

-
-
- {programs.map((p) => ( - - -

{p.title}

-

{p.subtitle}

-
- ))} -
-
-
- - -``` -(Se nell'archivio Figma sono presenti le icone originali dei programmi, usale al posto degli SVG inline: mappale nel Task 2 come `icon-.png`.) - -- [ ] **Step 7: Agenda.astro (condiviso)** - -Sezione scura con tab discipline e griglia settimanale: - -```astro ---- -import { agenda, disciplines, days, slots } from '../data/agenda'; ---- -
-
-

Agenda

-
- {disciplines.map((d, i) => ( - - ))} -
-
-
- {days.map((d) =>
{d}
)} - {slots.map((slot) => ( - <> -
{slot}
- {days.map((_, dayIdx) => { - const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot); - return entry - ?
{entry.discipline}{entry.time}{entry.trainer}
- :
; - })} - - ))} -
-
-
- - - - -``` - -- [ ] **Step 8: TeamSection.astro, MethodSteps.astro, PartnersStrip.astro** - -`TeamSection.astro` — titolo "TEAM TRAINER" a sinistra, bottone "Scopri team" → `/about`, 4 ritratti con nome verticale sul bordo: -```astro ---- -import { Image } from 'astro:assets'; -import { team, homeTeam } from '../../data/team'; -const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true }); -const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image); -const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; ---- -
-
-
-

Team
Trainer

- Scopri team -
-
- {members.map((m) => ( -
- {m.name} -
{m.shortName}
-
- ))} -
-
-
- - -``` - -`MethodSteps.astro` — intestazione "IL METODO INSANITYLAB" + 4 cerchi numerati con linea tratteggiata; nota extra sopra gli step 3–4: -```astro ---- -import { Image } from 'astro:assets'; -import { methodIntro, methodExtraNote, methodSteps } from '../../data/method'; -const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true }); -const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; ---- -
-
-
-

Il metodo
InsanityLab

-

Metodo Insanitylab')} /> -

-

{methodExtraNote}

-
- {methodSteps.map((s) => ( -
-
- {imgOf(s.image) && } - {s.n} -
-

{s.title}

-

{s.text}

-
- ))} -
-
-
- - -``` - -`PartnersStrip.astro` — striscia tan con i 4 loghi: -```astro ---- -import { Image } from 'astro:assets'; -import { partners } from '../../data/partners'; -const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true }); -const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; ---- -
-
- {partners.map((p) => imgOf(p.image) && {p.name})} -
-
- - -``` - -- [ ] **Step 9: componi index.astro** - -```astro ---- -import Base from '../layouts/Base.astro'; -import HeroSlider from '../components/home/HeroSlider.astro'; -import InfoColumns from '../components/home/InfoColumns.astro'; -import FeatureBlocks from '../components/home/FeatureBlocks.astro'; -import QuoteBanner from '../components/home/QuoteBanner.astro'; -import TrainingCards from '../components/home/TrainingCards.astro'; -import ProgramsGrid from '../components/home/ProgramsGrid.astro'; -import Agenda from '../components/Agenda.astro'; -import TeamSection from '../components/home/TeamSection.astro'; -import MethodSteps from '../components/home/MethodSteps.astro'; -import PartnersStrip from '../components/home/PartnersStrip.astro'; -export const prerender = true; ---- - - - - - -
-
-

Training

-

Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.

-
-
- - - - - - - -``` - -- [ ] **Step 10: confronto visivo con Figma** - -Run: `npm run dev`, apri `http://localhost:4321/` e confronta sezione per sezione con `/tmp/ilab-home-slice_*.png`. Correggi spaziature, dimensioni titoli, colori finché la corrispondenza è fedele (non pixel-perfect: stessa gerarchia, palette, proporzioni). Verifica mobile a 375px. - -- [ ] **Step 11: build e commit** - -Run: `npm run build` -Expected: build ok. - -```bash -git add -A && git commit -m "feat: homepage completa da design Figma" -``` - ---- - -### Task 10: Pagina About (`/about`) - -**Files:** -- Create: `src/pages/about.astro`, `src/components/about/Timeline.astro`, `src/components/PageHero.astro` - -**Interfaces:** -- Consumes: `team` (Task 8), immagini `about-*`/`team-*` (Task 2), `site`. -- Produces: `` riusato da training/programs/blog/contact (banner titolo pagina su fondo chiaro con eyebrow). - -Riferimento: `../FigmaDoc/Insanitylab.pdf` (generare fette come per la homepage). Sezioni nell'ordine: hero titolo "Insanitylab", Mission, Direzione (+ bottone Scopri), Approccio (+ bottone Contattaci), About, Storylab (timeline), Convenzioni, Team (3 fondatori + 6 trainer), Metodo (testo), In-Sanity Studios, Vision. - -- [ ] **Step 1: PageHero.astro** - -```astro ---- -interface Props { title: string; eyebrow?: string } -const { title, eyebrow } = Astro.props; ---- -
-
- {eyebrow &&

{eyebrow}

} -

{title}

-
-
- - -``` - -- [ ] **Step 2: Timeline.astro (Storylab)** - -Blocco 2021-2024 aperto con testo; 2025, 2026 e OGGI come accordion `
`: - -```astro ---- -const openText = `Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l’area dedicata alla riabilitazione e all’esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità.`; -const stages = [ - { label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' }, - { label: 'INSANITYLAB 2026', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' }, - { label: 'INSANITYLAB OGGI', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' }, -]; ---- -
-
-
-

Storylab

-

Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.

-
-
-

INSANITYLAB 2021-2024

-

{openText}

- {stages.map((s) => ( -
- + {s.label} -

{s.text}

-
- ))} -
-
-
- - -``` - -- [ ] **Step 3: about.astro** - -Testi reali da Insanitylab.pdf (già estratti — riportati integralmente qui sotto). Struttura: alternanza di sezioni testo/immagine come nel PDF. - -```astro ---- -import Base from '../layouts/Base.astro'; -import PageHero from '../components/PageHero.astro'; -import Timeline from '../components/about/Timeline.astro'; -import { Image } from 'astro:assets'; -import { team } from '../data/team'; -import { site } from '../data/site'; -const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true }); -const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default; -const founders = team.filter((m) => m.founder); -const staff = team.filter((m) => !m.founder); -export const prerender = true; ---- - - - -
-
-

Mission

-

In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.

-
-
- -
-
-
-

Direzione

-

Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.

- Scopri -
-
-

Approccio

-

InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.

- Contattaci -
-
-
- -
-
-

About

-

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.

-
-
- - - -
-
-
-

Convenzioni

-

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.

-
-
    -
  • 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.
  • -
  • Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.
  • -
  • Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell’azienda partner.
  • -
-

Per ricevere maggiori informazioni o attivare una convenzione: {site.email} · {site.phone}

-
-
- -
-
-
-

Team

-

Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.

-
-
- {founders.map((m) => ( -
- {m.image && imgOf(m.image) && {m.name}} -

{m.shortName}

-

{m.role}

-

{m.bio}

-
- ))} -
-

Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:

-
- {staff.map((m) => ( -
- {m.image && imgOf(m.image) && {m.name}} -

{m.shortName}

-

{m.role}

-

{m.bio}

-
- ))} -
-
-
- -
-
-

Metodo

-

Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.

-

In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.

-
-
- -
-
-

In-Sanity Studios

-

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.

-

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.

-

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.

-
-
- -
-
-

Vision

-

InsanityLab nasce con l’obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l’estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita.

-

L’obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità.

-
-
- - - -``` -Nota: se nell'archivio Figma trovi le immagini delle sezioni about (mission, direzione, in-sanity studios), aggiungi accanto ai testi con lo stesso pattern `Image` — confronta col PDF. - -- [ ] **Step 2b: i tre accordion Storylab (2025, 2026, OGGI) hanno testo placeholder esplicito "Contenuto in arrivo" — segnala all'operatore nel riepilogo finale del task che servono i testi dal cliente.** - -- [ ] **Step 4: verifica e commit** - -Run: `npm run dev`, confronta con le fette di `Insanitylab.pdf`; poi `npm run build`. - -```bash -git add -A && git commit -m "feat: pagina about" -``` - ---- - -### Task 11: Pagina Training (`/training`) - -**Files:** -- Create: `src/pages/training.astro` - -**Interfaces:** -- Consumes: `trainings` (Task 8), ``, `` (Task 9), `` (Task 10). - -Riferimento: `../FigmaDoc/Training.pdf`. Struttura: hero "training", intestazione Training, griglia 4 card (riuso `TrainingCards`), 4 blocchi alternati testo/immagine (one to one, small groups, performance class, fit remote) con ancore `id`, Agenda, CTA "Prenota la lezione di prova". - -- [ ] **Step 1: training.astro** - -```astro ---- -import Base from '../layouts/Base.astro'; -import PageHero from '../components/PageHero.astro'; -import TrainingCards from '../components/home/TrainingCards.astro'; -import Agenda from '../components/Agenda.astro'; -import { Image } from 'astro:assets'; -import { trainings } from '../data/trainings'; -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; -export const prerender = true; ---- - - - -
-
-

Training

-

Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.

-
-
- - -
-
- {trainings.map((t, i) => ( -
- {imgOf(t.image) && {t.title}} -
-

{t.title.toLowerCase()}

-

{t.description}

- {t.cta.label} -
-
- ))} -
-
- - - -
-
-
-

Prenota la lezione di prova

-

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.

- Prenota -
-
-
- - - -``` - -- [ ] **Step 2: verifica e commit** - -Run: `npm run dev`, confronta con `Training.pdf`; ancore `#one-to-one` ecc. funzionanti dal footer; `npm run build`. - -```bash -git add -A && git commit -m "feat: pagina training" -``` - ---- - -### Task 12: Pagine Programmi (`/programs`, `/programs/[slug]`) - -**Files:** -- Create: `src/pages/programs/index.astro`, `src/pages/programs/[slug].astro` - -**Interfaces:** -- Consumes: `programs`, `getProgram` (Task 8), `performanceClassPricing` (Task 8), ``. -- Produces: pagine prerenderizzate via `getStaticPaths`. - -Riferimento: `../FigmaDoc/Programmi.pdf` e `../FigmaDoc/Performance class.pdf`. - -- [ ] **Step 1: programs/index.astro** - -```astro ---- -import Base from '../../layouts/Base.astro'; -import PageHero from '../../components/PageHero.astro'; -import { programs } from '../../data/programs'; -export const prerender = true; ---- - - -
-
-
-

Programmi

-

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.

-
-
- {programs.map((p) => ( - - ))} -
-
-
- - - -``` - -- [ ] **Step 2: programs/[slug].astro** - -Layout da Performance class.pdf: FAQ accordion in alto, blocco descrizione con feature list, sidebar (categorie blog + altri programmi), pricing solo se `hasPricing`. - -```astro ---- -import Base from '../../layouts/Base.astro'; -import PageHero from '../../components/PageHero.astro'; -import { programs } from '../../data/programs'; -import { performanceClassPricing } from '../../data/pricing'; -export const prerender = true; - -export function getStaticPaths() { - return programs.map((p) => ({ params: { slug: p.slug }, props: { program: p } })); -} -const { program } = Astro.props; -const others = programs.filter((p) => p.slug !== program.slug); -const blogCategories = ['Fitness', 'Salute', 'Stile di vita', 'Nutrizione']; ---- - - - -
-
-
-

{program.title.toUpperCase()}

-

{program.longDescription}

-
    - {program.features.map((f) =>
  • {f}
  • )} -
-
- {program.faq.map((item, i) => ( -
- {item.q} -

{item.a}

-
- ))} -
-
- -
-
- - {program.hasPricing && ( -
-
-
-

{performanceClassPricing.heading}

-

{performanceClassPricing.intro}

-
- {performanceClassPricing.groups.map((g) => ( -
- {g.open ? '+' : '–'} {g.label} - {g.plans.length === 0 ? ( -

Contattaci per i dettagli di questo programma. Scrivici →

- ) : ( -
- {g.plans.map((p) => ( -
-

{p.monthly}€

-

al mese, semestrale

-

{p.entries} {p.entries === 1 ? 'ingresso settimanale' : 'ingressi settimanali'}

-

{p.note}

-

{p.installments}

-

{p.best}

- Acquista -
- ))} -
- )} -
- ))} -
-
- )} - - - -``` - -- [ ] **Step 3: verifica e commit** - -Run: `npm run dev`; controlla `/programs`, `/programs/performance-class` (pricing visibile, 3 card, accordion trimestrale/mensile chiusi), `/programs/rehab` (senza pricing). Confronta con i PDF. `npm run build` — devono comparire 6 pagine `programs/*` prerenderizzate. - -```bash -git add -A && git commit -m "feat: pagine programmi con dettaglio e pricing performance class" -``` - ---- - -### Task 13: Pagina Contatti e API email (TDD sulla validazione) - -**Files:** -- Create: `src/lib/contact.ts`, `src/lib/mailer.ts`, `src/pages/api/contact.ts`, `src/pages/contact.astro` -- Test: `tests/contact.test.ts` - -**Interfaces:** -- Consumes: `rateLimit` (Task 4), `getEnv` (Task 4), `` (Task 7), `` (Task 10), `site` (Task 8). -- Produces: - - `validateContact(data: unknown): { ok: true; value: ContactData } | { ok: false; error: string }` con `ContactData { firstName: string; lastName: string; phone: string; email: string; message: string; website: string }` (campi assenti → stringa vuota). - - `sendContactEmail(data: ContactData): Promise` (nodemailer). - - `POST /api/contact` → 200 `{ ok: true }` | 400 `{ error }` | 429 `{ error }` | 502 `{ error }`. - -- [ ] **Step 1: test validazione (falliranno)** - -`tests/contact.test.ts`: -```ts -import { describe, it, expect } from 'vitest'; -import { validateContact } from '../src/lib/contact'; - -describe('validateContact', () => { - const good = { firstName: 'Mario', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' }; - - it('accetta payload valido e normalizza i campi opzionali', () => { - const r = validateContact(good); - expect(r.ok).toBe(true); - if (r.ok) { - expect(r.value.lastName).toBe(''); - expect(r.value.email).toBe('mario@example.com'); - } - }); - - it('rifiuta email non valida', () => { - expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false); - }); - - it('rifiuta campi obbligatori mancanti o vuoti', () => { - expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false); - expect(validateContact({ ...good, message: '' }).ok).toBe(false); - expect(validateContact(null).ok).toBe(false); - }); - - it('rifiuta honeypot compilato', () => { - expect(validateContact({ ...good, website: 'spam.com' }).ok).toBe(false); - }); - - it('rifiuta messaggi oltre 5000 caratteri', () => { - expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false); - }); -}); -``` - -- [ ] **Step 2: verifica che falliscano** - -Run: `npx vitest run tests/contact.test.ts` — Expected: FAIL. - -- [ ] **Step 3: implementa contact.ts** - -`src/lib/contact.ts`: -```ts -export interface ContactData { - firstName: string; lastName: string; phone: string; - email: string; message: string; website: string; -} - -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -function str(v: unknown, max: number): string { - return typeof v === 'string' ? v.trim().slice(0, max + 1) : ''; -} - -export function validateContact(data: unknown): - | { ok: true; value: ContactData } - | { ok: false; error: string } { - if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' }; - const d = data as Record; - const value: ContactData = { - firstName: str(d.firstName, 100), - lastName: str(d.lastName, 100), - phone: str(d.phone, 40), - email: str(d.email, 200), - message: str(d.message, 5000), - website: str(d.website, 200), - }; - if (value.website) return { ok: false, error: 'Richiesta non valida.' }; - if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' }; - if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' }; - if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' }; - if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' }; - return { ok: true, value }; -} -``` -Nota: `str()` taglia a `max + 1` così un messaggio oltre il limite non viene accettato silenziosamente ma rifiutato dal check di lunghezza. - -- [ ] **Step 4: verifica test verdi** - -Run: `npm test` — Expected: PASS. - -- [ ] **Step 5: mailer.ts e api/contact.ts** - -`src/lib/mailer.ts`: -```ts -import nodemailer from 'nodemailer'; -import { getEnv } from './env'; -import type { ContactData } from './contact'; - -export async function sendContactEmail(data: ContactData): Promise { - const transporter = nodemailer.createTransport({ - host: getEnv('SMTP_HOST'), - port: Number(getEnv('SMTP_PORT', '587')), - secure: Number(getEnv('SMTP_PORT', '587')) === 465, - auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') }, - }); - await transporter.sendMail({ - from: getEnv('CONTACT_FROM'), - to: getEnv('CONTACT_TO', 'info@insanitylab.it'), - replyTo: data.email, - subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(), - text: [ - `Nome: ${data.firstName} ${data.lastName}`.trim(), - data.phone && `Telefono: ${data.phone}`, - `E-mail: ${data.email}`, - '', - data.message, - ].filter(Boolean).join('\n'), - }); -} -``` - -`src/pages/api/contact.ts`: -```ts -import type { APIRoute } from 'astro'; -import { validateContact } from '../../lib/contact'; -import { sendContactEmail } from '../../lib/mailer'; -import { rateLimit } from '../../lib/rate-limit'; - -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, clientAddress }) => { - if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) { - return json(429, { error: 'Troppe richieste, riprova più tardi.' }); - } - let data: unknown; - try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } - const result = validateContact(data); - if (!result.ok) return json(400, { error: result.error }); - try { - await sendContactEmail(result.value); - } catch (err) { - console.error('Invio email fallito:', err); - return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' }); - } - return json(200, { ok: true }); -}; -``` - -- [ ] **Step 6: contact.astro** - -Layout da Contatti.pdf: mappa a sinistra (click-to-load), form a destra, sotto indirizzo/telefono/email + invito. - -```astro ---- -import Base from '../layouts/Base.astro'; -import PageHero from '../components/PageHero.astro'; -import ContactForm from '../components/ContactForm.astro'; -import { site } from '../data/site'; -export const prerender = true; -const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`); ---- - - -
-
-
- -

La mappa viene caricata da Google solo dopo il tuo consenso.

-
- -
-
-
-

Indirizzo

-

{site.address} {site.cityLine}

-
-
-

Telefono

-

{site.phone}

-
-
-

E-mail

-

{site.email}

-
-
-
-

Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.

-
-
- - - - - -``` - -- [ ] **Step 7: verifica manuale API** - -Run (con dev server attivo): -```bash -curl -s -X POST localhost:4321/api/contact -H 'Content-Type: application/json' -d '{"firstName":"Test","email":"t@t.it","message":"ciao"}' -``` -Expected: `{"error":"Invio non riuscito..."}` (502, SMTP finto in `.env`) — la catena valida→invia funziona. Con honeypot: `-d '{"firstName":"T","email":"t@t.it","message":"x","website":"spam"}'` → 400. - -- [ ] **Step 8: commit** - -```bash -git add -A && git commit -m "feat: pagina contatti e api email con validazione testata" -``` - ---- - -### Task 14: Blog pubblico (`/blog`, `/blog/[slug]`) - -**Files:** -- Create: `src/pages/blog/index.astro`, `src/pages/blog/[slug].astro`, `src/lib/blog-const.ts`, `src/components/blog/Sidebar.astro` - -**Interfaces:** -- Consumes: `getDb`, `listPublished`, `getPublishedBySlug`, `listRecent`, `listArchiveMonths` (Task 5), ``. -- Produces: `CATEGORIES: string[]` in `blog-const.ts` (usato anche dall'admin, Task 15). - -- [ ] **Step 1: blog-const.ts** - -```ts -export const CATEGORIES = [ - 'Articoli tecnici & scientifici', - 'Contenuti educativi', - 'LinkedIn / Insight', - 'Eventi & Presidi', -] as const; -``` - -- [ ] **Step 2: blog/index.astro (SSR)** - -```astro ---- -import Base from '../../layouts/Base.astro'; -import PageHero from '../../components/PageHero.astro'; -import { getDb } from '../../lib/db'; -import { listPublished } from '../../lib/posts'; -import { CATEGORIES } from '../../lib/blog-const'; -export const prerender = false; - -const PER_PAGE = 9; -const url = Astro.url; -const category = url.searchParams.get('categoria') ?? undefined; -const page = Math.max(1, Number(url.searchParams.get('pagina') ?? '1') || 1); -const { items, total } = listPublished(getDb(), { - category: category && (CATEGORIES as readonly string[]).includes(category) ? category : undefined, - page, perPage: PER_PAGE, -}); -const pages = Math.max(1, Math.ceil(total / PER_PAGE)); -const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : ''; -const linkFor = (cat?: string, p = 1) => { - const q = new URLSearchParams(); - if (cat) q.set('categoria', cat); - if (p > 1) q.set('pagina', String(p)); - const s = q.toString(); - return `/blog${s ? `?${s}` : ''}`; -}; ---- - - -
-
-
-

Blog & Edugo

-

Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.

-
- - {items.length === 0 ? ( -

Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.

- ) : ( -
- {items.map((post) => ( - - ))} -
- )} - {pages > 1 && ( - - )} -
-
- - - -``` - -- [ ] **Step 3: Sidebar.astro** - -```astro ---- -import { getDb } from '../../lib/db'; -import { listRecent, listArchiveMonths } from '../../lib/posts'; -import { CATEGORIES } from '../../lib/blog-const'; -const db = getDb(); -const recent = listRecent(db, 4); -const months = listArchiveMonths(db); -const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`)); ---- - - - -``` - -- [ ] **Step 4: blog/[slug].astro (SSR)** - -```astro ---- -import Base from '../../layouts/Base.astro'; -import Sidebar from '../../components/blog/Sidebar.astro'; -import { getDb } from '../../lib/db'; -import { getPublishedBySlug } from '../../lib/posts'; -export const prerender = false; - -const post = getPublishedBySlug(getDb(), Astro.params.slug!); -if (!post) return Astro.redirect('/404'); -const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }); ---- - -
-
-
- {post.cover && } -

{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}

-

{post.title}

-
-
- -
-
- - - -``` -Nota: `body_html` è già sanitizzato al salvataggio (Task 15); `set:html` è accettabile solo per questo campo. - -- [ ] **Step 5: verifica con dati di prova** - -```bash -sqlite3 data/insanitylab.db "INSERT INTO posts (title, slug, category, excerpt, body_html, draft, published_at) VALUES ('Articolo di prova', 'articolo-di-prova', 'Contenuti educativi', 'Un estratto di prova.', '

Corpo di prova.

', 0, datetime('now'));" -curl -s localhost:4321/blog | grep -o 'Articolo di prova' | head -1 -curl -s localhost:4321/blog/articolo-di-prova | grep -o 'Corpo' | head -1 -curl -s -o /dev/null -w '%{http_code}' localhost:4321/blog/inesistente -``` -Expected: `Articolo di prova`, `Corpo`, redirect/404 per lo slug inesistente. Verifica filtri categoria e paginazione con più insert se serve. - -- [ ] **Step 6: commit** - -```bash -git add -A && git commit -m "feat: blog pubblico con filtri, paginazione e articolo" -``` - ---- - -### Task 15: Area admin — login, lista, editor TipTap, API CRUD e upload (TDD su sanitizzazione) - -**Files:** -- Create: `src/lib/sanitize.ts`, `src/lib/post-body.ts`, `src/layouts/Admin.astro`, `src/pages/admin/login.astro`, `src/pages/admin/logout.ts`, `src/pages/admin/index.astro`, `src/pages/admin/new.astro`, `src/pages/admin/edit/[id].astro`, `src/components/admin/PostForm.astro`, `src/pages/api/admin/posts/index.ts`, `src/pages/api/admin/posts/[id].ts`, `src/pages/api/admin/upload.ts`, `src/pages/uploads/[...path].ts` -- Test: `tests/sanitize.test.ts` - -**Interfaces:** -- Consumes: auth/middleware (Task 6), posts (Task 5), `slugify` (Task 4), `CATEGORIES` (Task 14), `rateLimit` (Task 4), `getEnv` (Task 4). -- Produces: - - `sanitizeHtml(html: string): string` in `src/lib/sanitize.ts`. - - `POST /api/admin/posts` body `{ title, slug?, category, excerpt, body_html, cover?, draft }` → 201 `{ id, slug }`. - - `PUT /api/admin/posts/[id]` stesso body → 200; `DELETE /api/admin/posts/[id]` → 200. - - `POST /api/admin/upload` (multipart, campo `file`) → 200 `{ url: '/uploads/' }`. - - `GET /uploads/` serve da `UPLOADS_DIR`. - -- [ ] **Step 1: test sanitizzazione (falliranno)** - -`tests/sanitize.test.ts`: -```ts -import { describe, it, expect } from 'vitest'; -import { sanitizeHtml } from '../src/lib/sanitize'; - -describe('sanitizeHtml', () => { - it('mantiene la formattazione consentita', () => { - const html = '

Titolo

Testo forte ed enfasi

  • voce
citazione
'; - expect(sanitizeHtml(html)).toBe(html); - }); - it('rimuove script e handler', () => { - expect(sanitizeHtml('

ciao

')).toBe('

ciao

'); - }); - it('consente immagini locali e https, blocca javascript:', () => { - expect(sanitizeHtml('')).toContain('/uploads/a.jpg'); - expect(sanitizeHtml('x')).toBe('x'); - }); -}); -``` - -- [ ] **Step 2: verifica che falliscano, poi implementa sanitize.ts** - -Run: `npx vitest run tests/sanitize.test.ts` — FAIL. Poi: - -`src/lib/sanitize.ts`: -```ts -import sanitize from 'sanitize-html'; - -export function sanitizeHtml(html: string): string { - return sanitize(html, { - allowedTags: ['h2', 'h3', 'p', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'br', 'hr'], - allowedAttributes: { a: ['href', 'rel', 'target'], img: ['src', 'alt'] }, - allowedSchemes: ['https', 'http', 'mailto'], - allowedSchemesAppliedToAttributes: ['href', 'src'], - allowProtocolRelative: false, - }); -} -``` - -Run: `npm test` — PASS. (Se `sanitize-html` mantiene `src` relativi di default lo verifichi col test; in caso contrario aggiungi `allowedSchemesByTag` o normalizza i percorsi.) - -- [ ] **Step 3: layout Admin e login** - -`src/layouts/Admin.astro`: -```astro ---- -interface Props { title: string } -const { title } = Astro.props; -const user = Astro.locals.user; ---- - - - - - - {title} — Admin InsanityLab - - - - -
- InsanityLab · Admin - - {user && <>Ciao, {user.username} Articoli Nuovo Esci Vedi sito ↗} - -
-
- - -``` - -`src/pages/admin/login.astro` (POST gestito nel frontmatter): -```astro ---- -import Admin from '../../layouts/Admin.astro'; -import { getDb } from '../../lib/db'; -import { login, SESSION_COOKIE } from '../../lib/auth'; -import { rateLimit } from '../../lib/rate-limit'; -export const prerender = false; - -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('/admin'); - } - error = 'Credenziali non valide.'; - } -} ---- - -

Accesso

- {error &&

{error}

} -
- - - -
-
-``` - -`src/pages/admin/logout.ts`: -```ts -import type { APIRoute } from 'astro'; -import { getDb } from '../../lib/db'; -import { logout, SESSION_COOKIE } from '../../lib/auth'; -export const prerender = false; - -export const GET: APIRoute = ({ cookies, redirect }) => { - const token = cookies.get(SESSION_COOKIE)?.value; - if (token) logout(getDb(), token); - cookies.delete(SESSION_COOKIE, { path: '/' }); - return redirect('/admin/login'); -}; -``` - -- [ ] **Step 4: lista articoli** - -`src/pages/admin/index.astro`: -```astro ---- -import Admin from '../../layouts/Admin.astro'; -import { getDb } from '../../lib/db'; -import { listAllPosts } from '../../lib/posts'; -export const prerender = false; -const posts = listAllPosts(getDb()); -const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' }); ---- - -

Articoli

-

+ Nuovo articolo

- - - - {posts.map((p) => ( - - - - - - - - ))} - -
TitoloCategoriaStatoAggiornato
{p.title}{p.category}{p.draft ? 'Bozza' : 'Pubblicato'}{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}
- {posts.length === 0 &&

Nessun articolo: creane uno nuovo.

} -
- - -``` - -- [ ] **Step 5: PostForm.astro con TipTap** - -```astro ---- -import { CATEGORIES } from '../../lib/blog-const'; -import type { Post } from '../../lib/posts'; -interface Props { post?: Post } -const { post } = Astro.props; ---- -
- - - - - - - -

{post?.cover && }

- -
- - - - - - - - - -
-
- - -

-
- - - - -``` - -`src/pages/admin/new.astro`: -```astro ---- -import Admin from '../../layouts/Admin.astro'; -import PostForm from '../../components/admin/PostForm.astro'; -export const prerender = false; ---- - -

Nuovo articolo

- - -
-``` - -`src/pages/admin/edit/[id].astro`: -```astro ---- -import Admin from '../../../layouts/Admin.astro'; -import PostForm from '../../../components/admin/PostForm.astro'; -import { getDb } from '../../../lib/db'; -import { getPostById } from '../../../lib/posts'; -export const prerender = false; -const post = getPostById(getDb(), Number(Astro.params.id)); -if (!post) return Astro.redirect('/admin'); ---- - -

Modifica articolo

- - -
-``` - -- [ ] **Step 6: API CRUD** - -`src/lib/post-body.ts` (parsing condiviso tra POST e PUT): -```ts -import { slugify } from './slug'; -import { sanitizeHtml } from './sanitize'; -import { CATEGORIES } from './blog-const'; - -export function parsePostBody(data: Record) { - const title = String(data.title ?? '').trim(); - if (!title) return { error: 'Il titolo è obbligatorio.' } as const; - const category = String(data.category ?? ''); - if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const; - const slug = slugify(String(data.slug ?? '') || title); - if (!slug) return { error: 'Slug non valido.' } as const; - return { - value: { - title, slug, category, - excerpt: String(data.excerpt ?? '').trim().slice(0, 300), - body_html: sanitizeHtml(String(data.body_html ?? '')), - cover: data.cover ? String(data.cover) : null, - draft: Boolean(data.draft), - }, - } as const; -} -``` - -`src/pages/api/admin/posts/index.ts`: -```ts -import type { APIRoute } from 'astro'; -import { getDb } from '../../../../lib/db'; -import { createPost } from '../../../../lib/posts'; -import { parsePostBody } from '../../../../lib/post-body'; -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 }) => { - let data: Record; - try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } - const parsed = parsePostBody(data); - if ('error' in parsed) return json(400, { error: parsed.error }); - try { - const id = createPost(getDb(), parsed.value); - return json(201, { id, slug: parsed.value.slug }); - } catch (err: any) { - if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' }); - throw err; - } -}; -``` - -`src/pages/api/admin/posts/[id].ts`: -```ts -import type { APIRoute } from 'astro'; -import { getDb } from '../../../../lib/db'; -import { getPostById, updatePost, deletePost } from '../../../../lib/posts'; -import { parsePostBody } from '../../../../lib/post-body'; -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); - if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' }); - let data: Record; - try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } - const parsed = parsePostBody(data); - if ('error' in parsed) return json(400, { error: parsed.error }); - try { - updatePost(getDb(), id, parsed.value); - return json(200, { ok: true }); - } catch (err: any) { - if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' }); - throw err; - } -}; - -export const DELETE: APIRoute = ({ params }) => { - deletePost(getDb(), Number(params.id)); - return json(200, { ok: true }); -}; -``` - -- [ ] **Step 7: upload e route file** - -`src/pages/api/admin/upload.ts`: -```ts -import type { APIRoute } from 'astro'; -import { writeFile, mkdir } from 'node:fs/promises'; -import { randomBytes } from 'node:crypto'; -import { join, extname } from 'node:path'; -import { getEnv } from '../../../lib/env'; -export const prerender = false; - -const ALLOWED: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' }; -const MAX_BYTES = 5 * 1024 * 1024; - -const json = (status: number, body: object) => - new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); - -export const POST: APIRoute = async ({ request }) => { - const form = await request.formData().catch(() => null); - const file = form?.get('file'); - if (!(file instanceof File)) return json(400, { error: 'Nessun file.' }); - const ext = extname(file.name).toLowerCase(); - if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' }); - if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' }); - const dir = getEnv('UPLOADS_DIR', 'uploads'); - await mkdir(dir, { recursive: true }); - const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`; - await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer())); - return json(200, { url: `/uploads/${name}` }); -}; -``` - -`src/pages/uploads/[...path].ts`: -```ts -import type { APIRoute } from 'astro'; -import { readFile } from 'node:fs/promises'; -import { join, normalize, extname } from 'node:path'; -import { getEnv } from '../../lib/env'; -export const prerender = false; - -const MIME: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' }; - -export const GET: APIRoute = async ({ params }) => { - const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, ''); - if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 }); - const mime = MIME[extname(rel).toLowerCase()]; - if (!mime) return new Response('Not found', { status: 404 }); - try { - const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel)); - return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } }); - } catch { - return new Response('Not found', { status: 404 }); - } -}; -``` - -- [ ] **Step 8: verifica end-to-end manuale** - -Con dev server attivo e utente creato (`npm run create-user -- admin prova1234`): -1. `curl -s -o /dev/null -w '%{http_code}' localhost:4321/admin` → `302` (redirect a login da non autenticati). -2. `curl -s -X POST localhost:4321/api/admin/posts -H 'Content-Type: application/json' -d '{}'` → `401`. -3. Browser: login su `/admin/login`, crea articolo con immagine nel corpo, salva come pubblicato, verifica su `/blog` e `/blog/`; modifica; elimina (con conferma). -Expected: flusso completo senza errori console. - -- [ ] **Step 9: commit** - -```bash -git add -A && git commit -m "feat: area admin con editor tiptap, api crud e upload immagini" -``` - ---- - -### Task 16: SEO, favicon, robots e smoke test finale - -**Files:** -- Create: `public/robots.txt`, `public/favicon.svg`, `scripts/smoke.sh` - -**Interfaces:** -- Consumes: tutto il sito. - -- [ ] **Step 1: robots e favicon** - -`public/robots.txt`: -``` -User-agent: * -Disallow: /admin -Disallow: /api -Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml -``` - -`public/favicon.svg` — battito/onda del logo InsanityLab su fondo scuro (ricava la forma dal logo estratto; in mancanza): -```svg - - - - -``` - -- [ ] **Step 2: smoke test** - -`scripts/smoke.sh`: -```bash -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/.." -npm run build -PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$! -trap 'kill $SRV' EXIT -sleep 2 -fail=0 -for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do - code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path") - echo "$code $path" - [ "$code" = "200" ] || fail=1 -done -code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin) -echo "$code /admin (atteso 302)" -[ "$code" = "302" ] || fail=1 -exit $fail -``` - -Run: `chmod +x scripts/smoke.sh && ./scripts/smoke.sh` -Expected: tutti `200`, `/admin` `302`, exit 0. (Il server standalone usa `PORT`; se la porta è occupata cambiala.) - -- [ ] **Step 3: controllo qualità finale** - -- `npm test` → tutti verdi. -- `npx astro check` → nessun errore. -- Lighthouse rapido (facoltativo): `npx lighthouse http://localhost:4399/ --quiet --chrome-flags='--headless'` — performance e SEO ≥ 90 attesi su pagine statiche. - -- [ ] **Step 4: commit** - -```bash -git add -A && git commit -m "chore: robots, favicon e smoke test" -``` - ---- - -### Task 17: Remote GitLab e push - -**Files:** -- Nessun file di progetto; solo configurazione git. - -- [ ] **Step 1: verifica accesso GitLab** - -Run: `command -v glab && glab auth status` -- Se `glab` autenticato: `glab repo create insanitylab-website --private --source . --remote origin --push`. -- Se `glab` assente o non autenticato: chiedi all'operatore l'URL del repo GitLab (o le credenziali/namespace) e usa `git remote add origin && git push -u origin main`. - -- [ ] **Step 2: verifica push** - -Run: `git remote -v && git log origin/main --oneline | head -3` -Expected: remote configurato, commit presenti sul remote. - ---- - -## Ordine di esecuzione e note - -1 → 2 → 3 → 4 → 5 → 6 → 8 → 7 → 9 → 10 → 11 → 12 → 13 → 14 → 15 → 16 → 17 -(Il Task 8 precede il 7 perché Header/Footer consumano `site.ts`; i task 9–15 dipendono da 7+8.) - -**Punti aperti da segnalare all'operatore a fine esecuzione:** -- Testi Storylab 2025/2026/OGGI mancanti (placeholder «Contenuto in arrivo»). -- Orario agenda = placeholder dal template (nomi trainer finti). -- URL social reali da confermare in `src/data/site.ts`. -- Credenziali SMTP reali da mettere in `.env` (ora finte). -- Testi estesi/FAQ dei programmi diversi da Performance Class: bozze ragionevoli da far validare al cliente. - - - - - diff --git a/docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md b/docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md deleted file mode 100644 index 788712f..0000000 --- a/docs/superpowers/plans/2026-07-05-contenuti-taggati-pannello-editor.md +++ /dev/null @@ -1,1082 +0,0 @@ -# Piano implementazione: contenuti taggati + pannello editor - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ogni testo e immagine delle pagine vetrina riceve un tag stabile, i contenuti vivono in SQLite modificabili da un pannello `/admin/content` con ruoli admin/editor, stili tramite preset chiusi e overlay annotate sul sito. - -**Architecture:** Tabella `content_blocks` nel DB SQLite esistente, seed versionato che importa i data file attuali, lib `content.ts` con cache in memoria invalidata al salvataggio, componenti Astro ``/``, pagine vetrina flip a SSR, API REST sotto `/api/admin/content`, pannello SSR con JS vanilla. - -**Tech Stack:** Astro 7 (adapter node standalone, `output: 'server'`), better-sqlite3, bcryptjs, sanitize-html, vitest. Zero librerie frontend nuove (vanilla JS, coerente col progetto). - -**Spec:** `docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md` - -## Global Constraints - -- Rispondere a tutti i vincoli della spec; UI e messaggi in italiano. -- Tag: formato `pagina.sezione.elemento`, minuscolo, separatore `.`, segmenti kebab-case. Regex di validazione: `/^[a-z0-9-]+(\.[a-z0-9-]+)+$/`. -- Nessun CSS arbitrario dal pannello: solo chiavi preset whitelisted. -- Stile codice: come esistente (2 spazi, singoli apici, commenti italiani solo per vincoli non ovvi). -- Test: `npm test` (vitest, file in `tests/*.test.ts`, DB `:memory:` via `createDb(':memory:')`). -- Commit frequenti, messaggi brevi in italiano stile repo (`feat:`, `fix:`, `docs:`, `test:`). -- Il sito renderizzato NON deve cambiare visivamente dopo l'estrazione (stessi testi, stesse classi, stesse immagini). - -## Deviazioni consapevoli dalla spec - -1. **Celle agenda escluse dal tagging**: le 28 celle di `agenda.ts` sono dati placeholder del template Prowess (trainer fittizi); il cliente consegnerà l'orario reale. Taggare 84 valori destinati a essere buttati è spreco. Si taggano titolo sezione, discipline, giorni e slot. Le celle si affronteranno quando arriva l'orario reale. -2. **Aria-label e attributi tecnici esclusi** (`aria-label="Apri menu"`, title iframe): non sono contenuto editoriale. -3. **Alt text**: restano prop nel codice (derivati da name/title taggati dove data-driven); non diventano tag separati. Eccezione: nessuna — gli alt hardcoded seguono il testo del tag corrispondente dove esiste. - -## Inventario tag - -L'inventario completo (tag → valore attuale → file sorgente) è nel report allegato in coda a questo piano (sezione "Appendice: inventario"). I task di estrazione vi fanno riferimento. - ---- - -### Task 1: Tabella content_blocks + colonna role - -**Files:** -- Modify: `src/lib/db.ts` (SCHEMA + migrazione additiva) -- Test: `tests/content-db.test.ts` - -**Interfaces:** -- Produces: tabella `content_blocks(tag PK, type, value, styles, updated_at, updated_by)`; colonna `users.role` DEFAULT `'admin'`. - -- [ ] **Step 1: test fallente** - -```ts -// tests/content-db.test.ts -import { describe, it, expect } from 'vitest'; -import { createDb } from '../src/lib/db'; - -describe('schema content_blocks e ruoli', () => { - it('crea la tabella content_blocks', () => { - const db = createDb(':memory:'); - const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[]; - expect(cols.map((c) => c.name)).toEqual( - expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by']) - ); - }); - - it('rifiuta type non valido', () => { - const db = createDb(':memory:'); - expect(() => - db.prepare(`INSERT INTO content_blocks (tag, type, value) VALUES ('x.y', 'video', '')`).run() - ).toThrow(); - }); - - it('users ha colonna role con default admin', () => { - const db = createDb(':memory:'); - db.prepare(`INSERT INTO users (username, password_hash) VALUES ('a', 'h')`).run(); - const row = db.prepare(`SELECT role FROM users WHERE username = 'a'`).get() as { role: string }; - expect(row.role).toBe('admin'); - }); - - it('migra un DB esistente senza colonna role', () => { - // 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) - const db = createDb(':memory:'); - expect(() => createDb(':memory:')).not.toThrow(); - expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4); - }); -}); -``` - -- [ ] **Step 2: eseguire** `npx vitest run tests/content-db.test.ts` — atteso: FAIL (tabella/colonna assenti). - -- [ ] **Step 3: implementazione** — in `src/lib/db.ts` aggiungere in coda alla costante `SCHEMA`: - -```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 '{}', - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_by TEXT -); -``` - -e in `createDb`, dopo `db.exec(SCHEMA)`, la migrazione additiva: - -```ts - // Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role. - const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]; - if (!userCols.some((c) => c.name === 'role')) { - db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`); - } -``` - -- [ ] **Step 4: eseguire** `npx vitest run tests/content-db.test.ts` — atteso: PASS. Poi `npm test` completo — atteso: tutto PASS. - -- [ ] **Step 5: commit** `git add src/lib/db.ts tests/content-db.test.ts && git commit -m "feat: tabella content_blocks e colonna role utenti"` - ---- - -### Task 2: Ruoli in auth + policy percorsi admin - -**Files:** -- Modify: `src/lib/auth.ts` -- Modify: `src/env.d.ts` -- Modify: `scripts/create-user.mjs` -- Test: `tests/roles.test.ts` - -**Interfaces:** -- Consumes: colonna `users.role` (Task 1). -- Produces: `createUser(db, username, password, role?: 'admin'|'editor'): number`; `getSessionUser(db, token): { id: number; username: string; role: string } | null`; `canAccessAdminPath(role: string, pathname: string): boolean`. - -- [ ] **Step 1: test fallente** - -```ts -// tests/roles.test.ts -import { describe, it, expect, beforeEach } from 'vitest'; -import type Database from 'better-sqlite3'; -import { createDb } from '../src/lib/db'; -import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth'; - -let db: Database.Database; -beforeEach(() => { db = createDb(':memory:'); }); - -describe('ruoli', () => { - it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => { - createUser(db, 'cliente', 'segreta123', 'editor'); - const token = login(db, 'cliente', 'segreta123')!; - expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' }); - }); - - it('createUser senza ruolo crea admin', () => { - createUser(db, 'adriano', 'segreta123'); - const token = login(db, 'adriano', 'segreta123')!; - expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' }); - }); - - it('admin accede a tutto, editor solo a contenuti e logout', () => { - expect(canAccessAdminPath('admin', '/admin')).toBe(true); - expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true); - expect(canAccessAdminPath('editor', '/admin/content')).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); - expect(canAccessAdminPath('editor', '/admin/new')).toBe(false); - expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false); - expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false); - }); -}); -``` - -- [ ] **Step 2: eseguire** `npx vitest run tests/roles.test.ts` — atteso: FAIL. - -- [ ] **Step 3: implementazione** in `src/lib/auth.ts`: - -```ts -export type Role = 'admin' | 'editor'; - -export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number { - const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') - .run(username, hashPassword(password), role); - return Number(res.lastInsertRowid); -} - -// Percorsi admin consentiti anche al ruolo editor. -const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/]; - -export function canAccessAdminPath(role: string, pathname: string): boolean { - if (role === 'admin') return true; - return EDITOR_ALLOWED.some((re) => re.test(pathname)); -} -``` - -`getSessionUser`: aggiungere `u.role` alla SELECT e al tipo di ritorno `{ id, username, role }`. - -`src/env.d.ts`: `user?: { id: number; username: string; role: string }`. - -`scripts/create-user.mjs`: terzo argomento opzionale `role` (default `admin`, valori ammessi `admin|editor`, errore altrimenti); la CREATE TABLE inline nello script aggiunge `role TEXT NOT NULL DEFAULT 'admin'` e l'INSERT include role con `ON CONFLICT ... SET role = excluded.role`. Uso: `npm run create-user -- [role]`. - -- [ ] **Step 4: eseguire** `npm test` — atteso: PASS (incluso `auth.test.ts` esistente, invariato). - -- [ ] **Step 5: commit** `git add -u tests/roles.test.ts && git commit -m "feat: ruoli admin/editor in auth e create-user"` - ---- - -### Task 3: Middleware con enforcement ruoli - -**Files:** -- Modify: `src/middleware.ts` - -**Interfaces:** -- Consumes: `canAccessAdminPath`, `getSessionUser` con role (Task 2). - -- [ ] **Step 1: implementazione** — in `src/middleware.ts`, dopo aver ottenuto `user` valido, aggiungere: - -```ts - if (!canAccessAdminPath(user.role, pathname)) { - if (pathname.startsWith('/api/')) { - return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), { - status: 403, headers: { 'Content-Type': 'application/json' }, - }); - } - return context.redirect('/admin/content'); - } -``` - -(import di `canAccessAdminPath` da `./lib/auth`). La logica di policy è già coperta dai test del Task 2; il middleware resta un guscio sottile. - -- [ ] **Step 2: verifica manuale** — `npm run dev` in background, poi: - -```bash -curl -s -o /dev/null -w '%{http_code}' http://localhost:4321/api/admin/posts # atteso 401 (anonimo) -``` - -Login admin da browser o curl con cookie: comportamento invariato. (La verifica editor completa avviene al Task 13 quando esiste `/admin/content`.) - -- [ ] **Step 3: commit** `git add src/middleware.ts && git commit -m "feat: middleware limita editor ai percorsi contenuti"` - ---- - -### Task 4: Preset stili - -**Files:** -- Create: `src/lib/style-presets.ts` -- Modify: `src/styles/global.css` (classi `.cs-*` in coda) -- Test: `tests/style-presets.test.ts` - -**Interfaces:** -- Produces: `STYLE_GROUPS: Record`; `type StyleGroup = 'size'|'color'|'weight'|'style'|'align'`; `validateStyles(input: unknown): { ok: true; styles: Record } | { ok: false; error: string }`; `stylesToClasses(styles: Record): string`. - -- [ ] **Step 1: test fallente** - -```ts -// tests/style-presets.test.ts -import { describe, it, expect } from 'vitest'; -import { validateStyles, stylesToClasses, STYLE_GROUPS } from '../src/lib/style-presets'; - -describe('style presets', () => { - it('accetta chiavi e valori in whitelist', () => { - const r = validateStyles({ size: 'l', color: 'accent', align: 'center' }); - expect(r).toEqual({ ok: true, styles: { size: 'l', color: 'accent', align: 'center' } }); - }); - - it('rifiuta gruppo sconosciuto e valore fuori lista', () => { - expect(validateStyles({ font: 'comic' }).ok).toBe(false); - expect(validateStyles({ size: 'xxl' }).ok).toBe(false); - expect(validateStyles('non-oggetto').ok).toBe(false); - }); - - it('mappa stili in classi cs-*', () => { - expect(stylesToClasses({ size: 's', weight: 'bold' })).toBe('cs-size-s cs-weight-bold'); - expect(stylesToClasses({})).toBe(''); - }); - - it('espone i gruppi attesi', () => { - expect(Object.keys(STYLE_GROUPS)).toEqual(['size', 'color', 'weight', 'style', 'align']); - }); -}); -``` - -- [ ] **Step 2: eseguire** `npx vitest run tests/style-presets.test.ts` — atteso: FAIL. - -- [ ] **Step 3: implementazione** - -```ts -// src/lib/style-presets.ts -export 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'], -} as const; - -export type StyleGroup = keyof typeof STYLE_GROUPS; - -export function validateStyles(input: unknown): - | { ok: true; styles: Record } - | { ok: false; error: string } { - if (typeof input !== 'object' || input === null || Array.isArray(input)) { - return { ok: false, error: 'Stili non validi.' }; - } - const out: Record = {}; - for (const [k, v] of Object.entries(input)) { - const allowed = (STYLE_GROUPS as Record)[k]; - if (!allowed) return { ok: false, error: `Gruppo stile sconosciuto: ${k}` }; - if (typeof v !== 'string' || !allowed.includes(v)) { - return { ok: false, error: `Valore non ammesso per ${k}: ${String(v)}` }; - } - out[k] = v; - } - return { ok: true, styles: out }; -} - -export function stylesToClasses(styles: Record): string { - return Object.entries(styles).map(([k, v]) => `cs-${k}-${v}`).join(' '); -} -``` - -In `src/styles/global.css`, in coda: - -```css -/* === Stili contenuto (preset pannello /admin/content) === */ -.cs-size-s { font-size: .85em; } -.cs-size-m { font-size: 1em; } -.cs-size-l { font-size: 1.25em; } -.cs-color-accent { color: var(--c-accent); } -.cs-color-accent-dark { color: var(--c-accent-dark); } -.cs-color-dark { color: var(--c-dark); } -.cs-color-heading { color: var(--c-heading); } -.cs-color-text { color: var(--c-text); } -.cs-color-text-light { color: var(--c-text-light); } -.cs-color-white { color: #fff; } -.cs-weight-normal { font-weight: 400; } -.cs-weight-bold { font-weight: 700; } -.cs-style-normal { font-style: normal; } -.cs-style-italic { font-style: italic; } -.cs-align-left { text-align: left; } -.cs-align-center { text-align: center; } -.cs-align-right { text-align: right; } -``` - -- [ ] **Step 4: eseguire** `npm test` — atteso: PASS. - -- [ ] **Step 5: commit** `git add src/lib/style-presets.ts src/styles/global.css tests/style-presets.test.ts && git commit -m "feat: preset stili contenuto con whitelist"` - ---- - -### Task 5: Seed contenuti + content lib con cache - -**Files:** -- Create: `src/data/content-seed.ts` -- Create: `src/lib/content.ts` -- Modify: `src/lib/db.ts` (sync seed in `createDb`) -- Modify: `src/lib/sanitize.ts` (aggiunta `sanitizeInline`) -- Test: `tests/content.test.ts` - -**Interfaces:** -- Consumes: `content_blocks` (Task 1), `stylesToClasses` (Task 4). -- Produces: - - Seed: `type ContentType = 'text'|'html'|'image'`; `interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }`; `const contentSeed: SeedEntry[]`; `const seedByTag: Map`. - - Lib: `resolveContent(tag: string): { value: string; type: ContentType; classes: string }`; `t(tag: string): string` (solo il valore); `getImageOverride(tag: string): string | null` (URL `/uploads/...` o null); `contentImageUrl(tag: string, fallback: string): string`; `invalidateContentCache(): void`; `syncSeed(db: Database): void`. - - Sanitize: `sanitizeInline(html: string): string` (solo `strong, em, u, s, br, a`). - -- [ ] **Step 1: test fallente** - -```ts -// tests/content.test.ts -import { describe, it, expect, beforeEach } from 'vitest'; -import type Database from 'better-sqlite3'; -import { createDb } from '../src/lib/db'; -import { contentSeed } from '../src/data/content-seed'; -import { makeContentStore, syncSeed } from '../src/lib/content'; - -let db: Database.Database; -beforeEach(() => { db = createDb(':memory:'); }); - -describe('seed', () => { - it('tag univoci, validi e valori non vuoti', () => { - const tags = contentSeed.map((e) => e.tag); - expect(new Set(tags).size).toBe(tags.length); - for (const e of contentSeed) { - expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/); - if (e.type !== 'image') expect(e.value.length).toBeGreaterThan(0); - } - }); - - it('createDb sincronizza il seed senza sovrascrivere modifiche', () => { - const n = (db.prepare('SELECT COUNT(*) AS n FROM content_blocks').get() as { n: number }).n; - expect(n).toBe(contentSeed.length); - db.prepare(`UPDATE content_blocks SET value = 'MODIFICATO' WHERE tag = ?`).run(contentSeed[0].tag); - // ri-sync (come a un nuovo avvio) non deve toccare la modifica - syncSeed(db); - const v = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(contentSeed[0].tag) as { value: string }; - expect(v.value).toBe('MODIFICATO'); - }); -}); - -describe('content store', () => { - it('legge dal DB con classi preset e cache invalidabile', () => { - const store = makeContentStore(() => db); - const tag = contentSeed[0].tag; - db.prepare(`UPDATE content_blocks SET value = 'Nuovo', styles = '{"size":"l"}' WHERE tag = ?`).run(tag); - store.invalidate(); - const r = store.resolve(tag); - expect(r.value).toBe('Nuovo'); - expect(r.classes).toBe('cs-size-l'); - // senza invalidate la cache resta - db.prepare(`UPDATE content_blocks SET value = 'Altro' WHERE tag = ?`).run(tag); - expect(store.resolve(tag).value).toBe('Nuovo'); - store.invalidate(); - expect(store.resolve(tag).value).toBe('Altro'); - }); - - it('fallback: tag assente nel DB usa il seed, sconosciuto stringa vuota', () => { - const store = makeContentStore(() => db); - db.prepare('DELETE FROM content_blocks WHERE tag = ?').run(contentSeed[0].tag); - store.invalidate(); - expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value); - expect(store.resolve('tag.inesistente').value).toBe(''); - }); - - it('styles corrotto nel DB viene ignorato', () => { - const store = makeContentStore(() => db); - db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag); - store.invalidate(); - expect(store.resolve(contentSeed[0].tag).classes).toBe(''); - }); -}); -``` - -- [ ] **Step 2: eseguire** `npx vitest run tests/content.test.ts` — atteso: FAIL. - -- [ ] **Step 3: implementazione seed (nucleo iniziale)** — `src/data/content-seed.ts` con tipi, helper e le sole entry GLOBALI per partire (i task di estrazione lo ampliano): - -```ts -import type { StyleGroup } from '../lib/style-presets'; -import { site } from './site'; - -export type ContentType = 'text' | 'html' | 'image'; -export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] } - -const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style']): SeedEntry => - ({ tag, type: 'text', value, styleOptions }); -const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): SeedEntry => - ({ tag, type: 'html', value, styleOptions }); -const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' }); - -export const contentSeed: SeedEntry[] = [ - // --- Globali (site.ts) --- - T('global.contact.phone', site.phone, []), - T('global.contact.email', site.email, []), - T('global.contact.address', site.address, []), - T('global.contact.city-line', site.cityLine, []), - T('global.hours.1', site.hours[0], []), - T('global.hours.2', site.hours[1], []), - // I task di estrazione aggiungono qui le entry di header/footer/pagine/data file. -]; - -export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); -``` - -- [ ] **Step 4: implementazione lib** — `src/lib/content.ts`: - -```ts -import type Database from 'better-sqlite3'; -import { getDb } from './db'; -import { contentSeed, seedByTag, type ContentType } from '../data/content-seed'; -import { stylesToClasses } from './style-presets'; - -interface Entry { value: string; type: ContentType; styles: Record } - -export function syncSeed(db: Database.Database): void { - const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); - const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); - tx(); -} - -export function makeContentStore(dbGetter: () => Database.Database) { - let cache: Map | null = null; - - const load = (): Map => { - if (cache) return cache; - const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as - { tag: string; type: ContentType; value: string; styles: string }[]; - cache = new Map(rows.map((r) => { - let styles: Record = {}; - try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ } - if (typeof styles !== 'object' || styles === null) styles = {}; - return [r.tag, { value: r.value, type: r.type, styles }]; - })); - return cache; - }; - - return { - resolve(tag: string): { value: string; type: ContentType; classes: string } { - const hit = load().get(tag); - if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) }; - const seed = seedByTag.get(tag); - if (seed) return { value: seed.value, type: seed.type, classes: '' }; - console.warn(`[content] tag sconosciuto: ${tag}`); - return { value: '', type: 'text', classes: '' }; - }, - invalidate(): void { cache = null; }, - }; -} - -const store = makeContentStore(getDb); - -export const resolveContent = (tag: string) => store.resolve(tag); -export const t = (tag: string): string => store.resolve(tag).value; -export const invalidateContentCache = (): void => store.invalidate(); - -export function getImageOverride(tag: string): string | null { - const r = store.resolve(tag); - return r.type === 'image' && r.value ? `/uploads/${r.value}` : null; -} - -export const contentImageUrl = (tag: string, fallback: string): string => - getImageOverride(tag) ?? fallback; -``` - -In `src/lib/db.ts`, `createDb` chiama il sync dopo schema e migrazioni. Per evitare import circolare (content.ts importa db.ts), il sync si fa con import dinamico differito NON praticabile in sync — soluzione: `syncSeed` vive in content.ts ma non importa db.ts a livello di modulo per la funzione; siccome `content.ts` importa `getDb` solo per lo store singleton, spostare `syncSeed` in un modulo dedicato è più pulito. **Scelta: crearlo in content.ts ma importare in db.ts SOLO il seed**, e replicare l'insert lì: - -```ts -// in src/lib/db.ts (import in testa: nessun ciclo — content-seed non importa db) -import { contentSeed } from '../data/content-seed'; -// ...in createDb, dopo migrazione role: - const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); - const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); - syncTx(); -``` - -e in content.ts `syncSeed` resta esportata per i test (stessa logica). In `src/lib/sanitize.ts` aggiungere: - -```ts -export function sanitizeInline(html: string): string { - return sanitize(html, { - allowedTags: ['strong', 'em', 'u', 's', 'br', 'a'], - allowedAttributes: { a: ['href', 'rel', 'target'] }, - allowedSchemes: ['https', 'http', 'mailto'], - allowProtocolRelative: false, - }); -} -``` - -- [ ] **Step 5: eseguire** `npm test` — atteso: PASS. - -- [ ] **Step 6: commit** `git add src/data/content-seed.ts src/lib/content.ts src/lib/db.ts src/lib/sanitize.ts tests/content.test.ts && git commit -m "feat: content store con seed, cache e fallback"` - ---- - -### Task 6: Componenti `` e `` - -**Files:** -- Create: `src/components/content/T.astro` -- Create: `src/components/content/TImg.astro` - -**Interfaces:** -- Consumes: `resolveContent`, `getImageOverride` (Task 5). -- Produces: `` (default `as="span"`; emette `data-tag`, classi preset accodate); `` (`src: ImageMetadata`; override → ``). - -- [ ] **Step 1: implementazione T.astro** - -```astro ---- -import { resolveContent } from '../../lib/content'; -interface Props { tag: string; as?: string; class?: string } -const { tag, as = 'span', class: cls } = Astro.props; -const Tag = as; -const c = resolveContent(tag); -const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined; ---- -{c.type === 'html' - ? - : {c.value}} -``` - -- [ ] **Step 2: implementazione TImg.astro** - -```astro ---- -import { Image } from 'astro:assets'; -import type { ImageMetadata } from 'astro'; -import { getImageOverride } from '../../lib/content'; -interface Props { - tag: string; src: ImageMetadata; alt: string; - class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; -} -const { tag, src, alt, class: cls, ...rest } = Astro.props; -const override = getImageOverride(tag); ---- -{override - ? {alt} - : {alt}} -``` - -- [ ] **Step 3: verifica sintassi** — `npx astro check` (accettare solo errori preesistenti, nessun nuovo errore nei due componenti). - -- [ ] **Step 4: commit** `git add src/components/content && git commit -m "feat: componenti T e TImg per contenuti taggati"` - ---- - -### Task 7: Estrazione — Header, Footer, ContactForm, globali - -**Files:** -- Modify: `src/data/content-seed.ts` (entry global/header/footer/form) -- Modify: `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro` - -**Interfaces:** -- Consumes: ``, `t()` (Task 5-6). -- Produces: tag `global.nav..label` (1-5), `header.logo.alt`, `footer.tagline`, `footer.about`, `footer.col.training-title`, `footer.col.programs-title`, `footer.col.contact-title`, `footer.copyright`, `footer.social-label`, `footer.training..label` (1-4), `footer.programs..label` (1-6), `form.field.first-name`, `form.field.last-name`, `form.field.phone`, `form.field.email`, `form.field.message`, `form.submit`, `form.msg.success`, `form.msg.error-generic`, `form.msg.error-network`. - -Pattern di estrazione (vale per tutti i task 7-12): - -1. Aggiungere le entry al seed copiando ESATTAMENTE il testo dal componente (helper `T()`/`H()`/`I()`). -2. Nel template sostituire il testo con `` quando il testo occupa un intero elemento; con `{t('...')}` quando è un frammento (placeholder, attributo, interno di elemento con altro markup). -3. `type: 'html'` SOLO dove il valore contiene markup (`
`, ` `, ``, link) — renderizzato con `set:html` via ``. -4. Testi nei `ok' }, 'a'); - const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any; - expect(row.value).not.toContain(' -)} -``` - -- [ ] **Step 2: verifica** — browser: `/?annotate=1` da loggato → badge visibili, click badge apre il form giusto; stessa URL in finestra anonima → nessun badge, nessun outline; pagina normale senza parametro → invariata (curl diff: `curl -s localhost:4321/ | grep -c tag-badge` → 0). -- [ ] **Step 3: commit** `git add -u && git commit -m "feat: overlay annotate per utenti loggati"` - ---- - -### Task 17: Utente editor, typecheck, smoke completo - -**Files:** -- Modify: `scripts/smoke.sh` (aggiunte route/asserzioni contenuti) - -- [ ] **Step 1**: creare utente editor locale: `npm run create-user -- editor prova1234 editor`. Verifica browser: login editor → atterra/limitato a `/admin/content`; `/admin` → redirect a `/admin/content`; PUT contenuto ok; `curl -b -X POST /api/admin/posts` → 403. -- [ ] **Step 2**: estendere `scripts/smoke.sh`: check `GET /sitemap.xml` 200 + ``, `GET /` contiene `data-tag=`, `GET /?annotate=1` anonimo NON contiene `tag-badge`, tutte le route vetrina 200. -- [ ] **Step 3**: `npx astro check && npm test && npm run build && ./scripts/smoke.sh` (contro build di produzione via `npm run start`). Tutto verde. -- [ ] **Step 4: commit** `git add -u && git commit -m "test: smoke contenuti taggati e utente editor"` - ---- - -### Task 18: Deploy produzione e verifica - -- [ ] **Step 1**: push al remote Gitea (`git push`). -- [ ] **Step 2**: sul VPS: `cd /opt/docker/insanitylab/src && git pull && docker compose up -d --build` (pattern consolidato del progetto). -- [ ] **Step 3**: creare utente editor in produzione dentro il container (`docker compose exec npm run create-user -- editor`); consegnare credenziali all'operatore (Vaultwarden). -- [ ] **Step 4: verifica produzione**: route 200 (home, about, programs, training, contact, sitemap.xml); login admin → `/admin/content` funziona; modifica di prova su un tag → visibile sul sito → ripristino; `/?annotate=1` da loggato mostra badge, da anonimo no; blog invariato. -- [ ] **Step 5**: aggiornare memoria progetto (`insanitylab-stato-progetto.md`) con stato nuovo sistema. - ---- - -## Appendice: inventario - -Riferimento completo tag → valore → file: `docs/superpowers/plans/2026-07-05-inventario-contenuti.md`. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione, non da quel documento. Conteggi: ~150 tag testo hardcoded nei template, ~200 stringhe da data file, ~60 slot immagine. Esclusioni motivate in "Deviazioni consapevoli". diff --git a/docs/superpowers/plans/2026-07-05-inventario-contenuti.md b/docs/superpowers/plans/2026-07-05-inventario-contenuti.md deleted file mode 100644 index 8a53dd6..0000000 --- a/docs/superpowers/plans/2026-07-05-inventario-contenuti.md +++ /dev/null @@ -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 `
    ` + 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..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..*` (vedi trainings.ts); nota `

    {t.title.toLowerCase()}

    ` (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-.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-.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..label` (5 voci: "Insanitylab", "Training", "Programmi", "Blog & Edugo", "Contatti"). Aria-label esclusi. - -### `Footer.astro` -Logo light + alt; `footer.tagline` (html, "PERFORMANCE.  BALANCE.  LONGEVITY."); `footer.about` ("Promuoviamo il movimento come cura di sé…"); riga contatti con `
    ` (comporre dai tag `global.contact.*`); orari (`global.hours.1/2`); titoli colonne training/programs/contact; `footer.copyright` ("Insanitylab, All Rights Reserved" — `` e anno dinamico restano fuori dal tag); `footer.social-label` ("Seguici"); label link da `site.footerTraining` (4) e `site.footerPrograms` (6) → `footer.training..label`, `footer.programs..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 ` - )} -``` - -- [ ] **Step 2: Verifica build** - -Run: `npm run build` -Expected: build OK. - -- [ ] **Step 3: Verifica manuale in dev (superuser)** - -Crea un utente superuser locale: `npm run create-user -- super prova1234 superuser`. Avvia dev (`fuser -k 4321/tcp; npm run dev &`), fai login su `/login`, attiva "Mostra tag", verifica i badge con guid e apri il modal su un tag testo: modifica → salva → il testo cambia in pagina senza reload. Ferma il server. Documenta l'esito nel report. - -- [ ] **Step 4: Commit** - -```bash -git add src/layouts/Base.astro -git commit -m "feat(inline): badge con guid + modal di modifica contenuti in pagina" -``` - ---- - -### Task 11: Nav admin per 3 ruoli e blog filtrato per autore - -**Files:** -- Modify: `src/layouts/Admin.astro` -- Modify: `src/pages/admin/index.astro` - -**Interfaces:** -- Consumes: `listAllPosts`, `listByAuthor` (posts.ts), `listUsers` (auth.ts), `Astro.locals.user`. - -- [ ] **Step 1: Nav admin per ruolo** - -In `src/layouts/Admin.astro`, sostituisci le due righe dentro `` (righe ~34-35) con: - -```astro - {user && ( - <> - {`Ciao, ${user.username}`} - Articoli - {(user.role === 'superuser' || user.role === 'admin') && Contenuti} - {user.role === 'admin' && Utenti} - Nuovo - Esci - Vedi sito ↗ - - )} -``` - -- [ ] **Step 2: Blog admin filtrato + colonna autore** - -Sostituisci il frontmatter di `src/pages/admin/index.astro`: - -```astro ---- -import Admin from '../../layouts/Admin.astro'; -import { getDb } from '../../lib/db'; -import { listAllPosts, listByAuthor } from '../../lib/posts'; -import { listUsers } from '../../lib/auth'; -export const prerender = false; -const user = Astro.locals.user!; -const isManager = user.role === 'superuser' || user.role === 'admin'; -const posts = 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' }); ---- -``` - -Nella `` aggiungi la colonna Autore solo per i manager, e nella riga il valore. Sostituisci la tabella: - -```astro - - {isManager && } - - {posts.map((p) => ( - - - - {isManager && } - - - - - ))} - -
    TitoloCategoriaAutoreStatoAggiornato
    {p.title}{p.category}{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}{p.draft ? 'Bozza' : 'Pubblicato'}{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}
    -``` - -- [ ] **Step 3: Verifica build** - -Run: `npm run build` -Expected: build OK. - -- [ ] **Step 4: Commit** - -```bash -git add src/layouts/Admin.astro src/pages/admin/index.astro -git commit -m "feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore" -``` - ---- - -### Task 12: Firma autore nel blog pubblico - -**Files:** -- Modify: `src/pages/blog/[slug].astro` - -**Interfaces:** -- Consumes: `getUsernameById` (auth.ts), `post.author_id`. - -- [ ] **Step 1: Aggiungi la firma** - -In `src/pages/blog/[slug].astro`, aggiungi l'import e calcola l'autore nel frontmatter: - -```astro -import { getUsernameById } from '../../lib/auth'; -``` - -Dopo `const post = getPublishedBySlug(...)` e il check 404, aggiungi: - -```typescript -const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null; -const byline = author ?? 'Staff InsanityLab'; -``` - -Nel markup, cambia la riga meta per includere la firma: - -```astro -

    {post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}

    -``` - -- [ ] **Step 2: Verifica build** - -Run: `npm run build` -Expected: build OK. - -- [ ] **Step 3: Commit** - -```bash -git add src/pages/blog/[slug].astro -git commit -m "feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab" -``` - ---- - -### Task 13: Gestione utenti — pagina e API - -**Files:** -- Modify: `src/lib/auth.ts` (helper gestione) -- Create: `src/pages/api/admin/users/index.ts` -- Create: `src/pages/api/admin/users/[id]/role.ts` -- Create: `src/pages/api/admin/users/[id]/reset-password.ts` -- Create: `src/pages/api/admin/users/[id]/index.ts` -- Create: `src/pages/admin/users.astro` -- Test: `tests/users.test.ts` - -**Interfaces:** -- Consumes: `createUser`, `hashPassword`, `listUsers` (auth.ts), `Astro.locals.user`. -- Produces in `auth.ts`: - - `countAdmins(db): number` - - `getUserById(db, id): { id: number; username: string; role: string } | null` - - `updateUserRole(db, id, role: Role): void` - - `updateUserPassword(db, id, hash: string): void` - - `deleteUser(db, id): void` - - `randomPassword(): string` - - `isRole(v: unknown): v is Role` - -- [ ] **Step 1: Test degli helper gestione** - -Crea `tests/users.test.ts`: - -```typescript -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 l’hash', () => { - 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); - }); -}); -``` - -- [ ] **Step 2: Esegui e verifica il fallimento** - -Run: `npm test -- tests/users.test.ts` -Expected: FAIL — helper non esistono. - -- [ ] **Step 3: Aggiungi gli helper in `auth.ts`** - -```typescript -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'; -} -``` - -- [ ] **Step 4: Esegui e verifica il successo** - -Run: `npm test -- tests/users.test.ts` -Expected: PASS. - -- [ ] **Step 5: Crea l'API di creazione/lista** - -Crea `src/pages/api/admin/users/index.ts`: - -```typescript -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; - } -}; -``` - -- [ ] **Step 6: Crea l'API cambio ruolo (con guardia ultimo admin)** - -Crea `src/pages/api/admin/users/[id]/role.ts`: - -```typescript -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 }); -}; -``` - -- [ ] **Step 7: Crea l'API reset password** - -Crea `src/pages/api/admin/users/[id]/reset-password.ts`: - -```typescript -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 }); -}; -``` - -- [ ] **Step 8: Crea l'API elimina (guardie self + ultimo admin)** - -Crea `src/pages/api/admin/users/[id]/index.ts`: - -```typescript -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 }); -}; -``` - -- [ ] **Step 9: Crea la pagina `/admin/users`** - -Crea `src/pages/admin/users.astro`: - -```astro ---- -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!; ---- - -

    Utenti

    - -

    Nuovo utente

    -
    - - - - - -
    - -

    Elenco

    - - - - {users.map((u) => ( - - - - - - ))} - -
    UtenteRuolo
    {u.username}{u.id === me.id && ' (tu)'} - - - - {u.id !== me.id && } -
    -

    -
    - - -``` - -- [ ] **Step 10: Verifica build + test** - -Run: `npm run build && npm test` -Expected: build OK, tutti i test verdi. - -- [ ] **Step 11: Commit** - -```bash -git add src/lib/auth.ts src/pages/api/admin/users src/pages/admin/users.astro tests/users.test.ts -git commit -m "feat(users): gestione utenti /admin/users con guardie self/ultimo-admin" -``` - ---- - -### Task 14: Smoke esteso e verifica end-to-end - -**Files:** -- Modify: `scripts/smoke.sh` - -**Interfaces:** -- Consumes: build di produzione, utenti locali di prova. - -- [ ] **Step 1: Estendi lo smoke** - -In `scripts/smoke.sh`, aggiungi controlli (adeguandoti allo stile e alle variabili già presenti nel file — porta, `BASE`, helper di asserzione esistenti). Aggiungi: - -```bash -# /login pubblico raggiungibile -code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/login") -[ "$code" = "200" ] || { echo "FAIL: /login $code"; exit 1; } - -# Header mostra "Accedi" da anonimo -curl -s "$BASE/" | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; exit 1; } - -# Nessun badge/modal da anonimo -if curl -s "$BASE/" | grep -q 'tag-badge'; then echo "FAIL: badge visibile da anonimo"; exit 1; fi - -# Rotta gestione utenti protetta (redirect al login da anonimo) -code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/admin/users") -[ "$code" = "302" ] || { echo "FAIL: /admin/users non protetta ($code)"; exit 1; } - -echo "smoke ruoli/login/inline OK" -``` - -- [ ] **Step 2: Esegui lo smoke sulla build di produzione** - -Run: `fuser -k 4399/tcp 2>/dev/null; npm run build && bash scripts/smoke.sh` -Expected: exit 0, riga finale `smoke ruoli/login/inline OK`. (Ricorda: `fuser -k 4399/tcp` prima, per evitare il falso positivo del server stantio.) - -- [ ] **Step 3: Verifica funzionale locale completa** - -Crea tre utenti: `npm run create-user -- u1 prova1234 user`, `npm run create-user -- s1 prova1234 superuser` (l'admin esiste già). Con dev server, verifica in browser: -- `user`: vede solo i propri articoli, non `/admin/content` (redirect), non `/admin/users`; crea un articolo → compare firmato. -- `superuser`: vede tutti gli articoli con colonna Autore, apre `/admin/content`, attiva "Mostra tag" e modifica un tag inline; non vede `/admin/users`. -- `admin`: apre `/admin/users`, crea un utente, reset password (mostra la password una volta), cambia ruolo, prova a eliminare se stesso (bloccato) e prova a declassare l'unico admin (bloccato). -Documenta gli esiti nel report del task. - -- [ ] **Step 4: Commit** - -```bash -git add scripts/smoke.sh -git commit -m "test(smoke): login pubblico, badge, protezione /admin/users" -``` - ---- - -### Task 15: Deploy in produzione e verifica - -**Files:** nessuna modifica di codice. - -**Interfaces:** -- Consumes: repo Gitea, VPS `/opt/docker/insanitylab/src`. - -- [ ] **Step 1: Push del branch/merge in main** - -Segui la convenzione del progetto (merge ff in `main` come nei piani precedenti) e push su Gitea. Verifica albero pulito con `git status`. - -- [ ] **Step 2: Deploy sul VPS** - -Run (via ssh `HOSTINGER_Tielogic_XYZ`): `cd /opt/docker/insanitylab/src && git pull && docker compose up -d --build` -Expected: build immagine OK, container `insanitylab` up. - -- [ ] **Step 3: Verifica la migrazione dei ruoli in produzione** - -L'utente `editor` di produzione deve risultare `superuser` dopo il boot (la migrazione gira in `createDb`). Verifica: -Run: `docker compose exec -T insanitylab node -e "const D=require('better-sqlite3');const db=new D(process.env.DB_PATH||'data/insanitylab.db');console.log(db.prepare('SELECT username, role FROM users').all())"` -Expected: nessun ruolo `editor`; l'ex editor è `superuser`. - -- [ ] **Step 4: Verifica funzionale in produzione** - -- `https://insanitylab.tielogic.xyz/login` → 200, form stile sito. -- Login con l'ex editor (ora superuser): header mostra il nome, "Mostra tag" attivabile, modal inline salva un tag e il sito riflette la modifica; poi ripristina il valore. -- `https://insanitylab.tielogic.xyz/admin/users` con l'admin: crea/reset/ruolo/elimina funzionano; guardie attive. -- Un GUID è presente come `data-guid` nel sorgente della home. -- Blog pubblico: un articolo con autore mostra la firma. -Documenta gli esiti. - -- [ ] **Step 5: Aggiorna la memoria di progetto** - -Aggiorna `~/.claude/projects/-home-adriano-Documenti-Clienti-InsanityLab/memory/insanitylab-stato-progetto.md` con: sistema 3 ruoli, login pubblico, GUID contenuti, modifica inline, gestione utenti — deployati. Nessuna credenziale nei file di memoria. - ---- - -## Self-Review - -**Spec coverage:** -- Ruoli + matrice → Task 1 (migrazione), Task 2 (matrice), Task 3 (enforcement). ✓ -- GUID interno + nome leggibile → Task 1 (colonna/backfill), Task 6 (resolve/componenti/GET). ✓ -- editor → superuser → Task 1. ✓ -- Pagina `/login` + `?next` sicuro + alias `/admin/login` → Task 7 (alias resta: `/admin/login.astro` non rimosso). ✓ -- Nome nel header + menu → Task 8. ✓ -- Blog ownership (solo propri per user, tutti per superuser/admin, firma autore) → Task 4/5 (repo+API), Task 11 (lista), Task 12 (firma pubblica). ✓ -- Toggle "Mostra tag" persistente + badge + modifica inline modal → Task 9 (toggle/cookie), Task 10 (badge+modal). ✓ -- Gestione utenti completa (crea, reset psw una-volta, ruolo, elimina; guardie self/ultimo-admin) → Task 13. ✓ -- Testing (unit matrice, ownership, migrazioni, gestione utenti; smoke) → Task 1/2/4/5/6/13 + Task 14. ✓ -- Fuori scope (no framework, no tabella permessi, no self-registration) → rispettato: tutto vanilla, ruoli fissi. ✓ - -**Placeholder scan:** nessun TBD/TODO; ogni step ha codice o comando reale. Le due note di contingenza (routing GET in Task 6, stato pulsante in Task 9) contengono entrambe la correzione esatta da applicare, non un rimando generico. - -**Type consistency:** `Role` unica definizione (Task 2), riusata da `createUser`/`updateUserRole`/`isRole`. `canModifyPost(role, userId, post)` firma coerente tra Task 5 (def) e API. `resolveContent` ritorna `guid` (Task 6) consumato da T/TImg e — indirettamente via query — dal GET singolo. `listUsers`/`getUsernameById`/`getUserById`/`countAdmins` definiti in Task 4/13 e consumati da Task 11/12/13 con le stesse firme. `safeNext` (Task 7) usato solo in `/login`. - -## Execution Handoff - -Salvo qui sotto. Due opzioni di esecuzione: - -1. **Subagent-Driven (consigliata)** — un subagent fresco per task + review in due fasi tra un task e l'altro, iterazione rapida. -2. **Inline** — eseguo i task in questa sessione con checkpoint di review a batch. diff --git a/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md b/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md deleted file mode 100644 index f0c7af1..0000000 --- a/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md +++ /dev/null @@ -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). diff --git a/docs/superpowers/specs/2026-07-02-animazioni-template-design.md b/docs/superpowers/specs/2026-07-02-animazioni-template-design.md deleted file mode 100644 index f3ae7ae..0000000 --- a/docs/superpowers/specs/2026-07-02-animazioni-template-design.md +++ /dev/null @@ -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 `` aggiunge la classe `js` a `` 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). diff --git a/docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md b/docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md deleted file mode 100644 index 8858cbf..0000000 --- a/docs/superpowers/specs/2026-07-05-contenuti-taggati-pannello-editor-design.md +++ /dev/null @@ -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: - - ``: 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. - - ``: 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#` 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 ``/``, 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. diff --git a/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md b/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md deleted file mode 100644 index d2250a3..0000000 --- a/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md +++ /dev/null @@ -1,210 +0,0 @@ -# Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design - -Data: 2026-07-05 -Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz) - -## Obiettivo - -Portare il sito da due ruoli (`admin` | `editor`) a un sistema a tre ruoli con login pubblico, -identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile -per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay -attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente. - -## Ruoli e matrice permessi - -Tre ruoli su `users.role`: `admin`, `superuser`, `user`. Il vecchio ruolo `editor` viene migrato a -`superuser`. - -| Capacità | user | superuser | admin | -|---|:---:|:---:|:---:| -| Login + nome nell'header + logout | ✓ | ✓ | ✓ | -| Blog: CRUD **solo dei propri** articoli | ✓ | ✓ | ✓ | -| Blog: gestione articoli **di tutti gli autori** | | ✓ | ✓ | -| Pannello `/admin/content` | | ✓ | ✓ | -| Toggle "Mostra tag" + modifica inline in pagina | | ✓ | ✓ | -| Gestione utenti `/admin/users` (crea, reset psw, ruolo, elimina) | | | ✓ | - -La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità. - -## Modello dati (migrazioni idempotenti in `src/lib/db.ts`, prima di `syncSeed`) - -Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione). - -1. **Ruolo `editor` → `superuser`** - `UPDATE users SET role = 'superuser' WHERE role = 'editor'`. - Idempotente: al secondo giro nessuna riga `editor` resta. - -2. **GUID contenuti** — colonna `content_blocks.guid TEXT`. - - `ALTER TABLE content_blocks ADD COLUMN guid TEXT` se assente (PRAGMA table_info). - - Backfill: per ogni riga con `guid IS NULL OR guid = ''`, assegna `crypto.randomUUID()`. - - Indice univoco: `CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)` - (creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo). - - `syncSeed` (in `db.ts` e in `content.ts`): l'INSERT delle righe seed nuove valorizza il guid con - `crypto.randomUUID()` alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE), - quindi il loro guid è quello del backfill. - - Il **tag semantico resta la chiave primaria** e resta l'identificatore usato nel codice (`t('...')`, - ``) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno - (attributo `data-guid` nel DOM, badge, eventuale API/link). Nessun tag cambia nome. - -3. **Autore articoli** — colonna `posts.author_id INTEGER` (nullable). - - `ALTER TABLE posts ADD COLUMN author_id INTEGER` se assente. - - Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite - sono solo su `sessions`); l'integrità è gestita nel codice. - - Post storici: `author_id` resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico. - -## Auth e sessioni (`src/lib/auth.ts`) - -- `Role` diventa `'admin' | 'superuser' | 'user'`. `createUser` accetta i tre ruoli. -- `SESSION_COOKIE`, `login`, `getSessionUser`, `logout` restano invariati. -- Nuova funzione di autorizzazione che sostituisce `EDITOR_ALLOWED`/`canAccessAdminPath` con una - **matrice esplicita per prefisso di rotta**: - - ``` - /admin/users, /api/admin/users* → admin - /admin/content, /api/admin/content* → superuser, admin - /admin (blog: index/new/edit/posts), - /api/admin/posts*, /api/admin/upload → user, superuser, admin - /admin/logout, /admin/login → chiunque (login pubblico) - ``` - - Firma proposta: `canAccessAdminPath(role, pathname): boolean`. La landing per redirect dipende dal - ruolo (funzione `landingFor(role)`): `user → /admin` (lista blog), `superuser → /admin/content`, - `admin → /admin`. -- L'hashing password è già in `hashPassword`/`verifyPassword`; `scripts/create-user.mjs` continua a - funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di `auth.ts`. - -## Middleware (`src/middleware.ts`) - -Struttura invariata (protegge `/admin` e `/api/admin`, esclude `/admin/login`). Cambia solo il ramo -autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina → -`redirect(landingFor(role))`; su una rotta API → `403` JSON. `context.locals.user` continua a esporre -`{ id, username, role }`. - -## Login pubblico e header - -### Pagina `/login` (`src/pages/login.astro`) -- Layout `Base` (stile sito pubblico), **non** `Admin`. -- Form username+password. Riusa la logica di `/admin/login`: stesso `rateLimit`, stesso `login()`, - stesso set del cookie `session` (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni). -- Query `?next=`: dopo login valido redirige a `next` se è un path interno sicuro (inizia con `/`, - non `//`, non `/api/`); altrimenti alla home `/`. -- Se già loggato: redirige a `next` sicuro o a `/`. -- `/admin/login` resta come alias funzionante (nessuna rimozione). - -### Header (`src/components/Header.astro`, già incluso da `Base.astro`) -Reso SSR con lettura sessione dal cookie: -- **Anonimo**: link "Accedi" → `/login?next=`. -- **Loggato**: nome utente + menu a tendina: - - "Area riservata" → `landingFor(role)`. - - Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva. - - "Esci" → `/admin/logout`. -- Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie. - -## Toggle "Mostra tag" e modifica inline - -### Attivazione -- Cookie `showtags=1` (non httpOnly, così lo script client può leggerlo; path `/`), impostato/rimosso - dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica - il ruolo). -- `Base.astro` calcola `showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin` e - passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più `?annotate=1` da - trascinare nell'URL). -- Compat: `?annotate=1` continua a funzionare attivando il cookie/flag per la richiesta corrente. - -### Badge -- Quando `showTags` è attivo, lo script badge esistente in `Base.astro` (overlay assoluto sugli - elementi con `data-tag`) viene esteso per: mostrare il **nome tag**, esporre il **`data-guid`**, e - aggiungere un pulsante matita ✎ cliccabile. -- Nessun badge per i visitatori anonimi o per il ruolo `user` (lo script e il markup non vengono - nemmeno emessi). - -### Modal di modifica inline -Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al -pannello): -- **Nuovo endpoint** `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }` - (`styleOptions` da `seedByTag`/style-presets, come già fa `GET /api/admin/content`). -- Il form si costruisce in base a `type`: - - `text` / `html` → textarea + eventuali select di stile dagli `styleOptions`; salva con - **`PUT /api/admin/content/[tag]`** (esistente, ritorna `{ ok, value }` post-sanitizzazione). - - `image` → input file → **`POST /api/admin/content/[tag]/image`** (esistente) e rimozione via - **`DELETE`** (esistente). -- Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal `value` ritornato) e si - chiude, senza reload. Errori mostrati dentro il modal. -- Il codice del modal (markup + script) è caricato **solo** quando `showTags` è attivo: zero peso per - chi non è superuser/admin con toggle attivo. - -### Riuso vs nuovo -Le API di scrittura contenuti (PUT, POST/DELETE image) restano **invariate** — sono le stesse usate dal -pannello `/admin/content`. L'unica aggiunta backend è il `GET /api/admin/content/[tag]` singolo. Il -modal è l'unico componente frontend nuovo. - -## Gestione utenti (`/admin/users`, solo admin) - -### Pagina `src/pages/admin/users.astro` (layout `Admin`) -Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per: -crea, reset password, cambia ruolo, elimina. - -### API `src/pages/api/admin/users/*` (admin-only via middleware) -- **`POST /api/admin/users`** → crea utente: `username` + `password` + `role` - (`user`|`superuser`|`admin`). Rifiuta username duplicato (409/400 con messaggio). Usa `createUser`. -- **`POST /api/admin/users/[id]/reset-password`** → genera password casuale, la ritorna **una sola - volta** in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiorna `password_hash`. - Non viene mai ri-mostrata né loggata. -- **`PUT /api/admin/users/[id]/role`** → cambia ruolo. -- **`DELETE /api/admin/users/[id]`** → elimina l'utente. - -### Guardie (invarianti di sicurezza) -- Non puoi **eliminare te stesso** (confronto con `locals.user.id`). -- Non puoi eliminare **l'ultimo admin** rimasto, né declassarne il ruolo se è l'ultimo admin - (query di conteggio `role='admin'` nella stessa transazione). -- Eliminazione utente → cascade sulle sue sessioni (già `ON DELETE CASCADE` su `sessions.user_id`). - Gli articoli con `author_id` di un utente eliminato: `author_id` resta valorizzato ma "orfano" → - firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post). - -## Blog ownership - -### API `/api/admin/posts*` e `/api/admin/posts/[id]` -- **`user`**: la lista in `/admin` è filtrata sui propri `author_id`; `POST` forza - `author_id = locals.user.id`; `PUT`/`DELETE` su un post con `author_id` diverso → **403**. -- **`superuser` / `admin`**: vedono e gestiscono tutti i post; il `POST` valorizza comunque - `author_id = locals.user.id` (l'autore è chi crea). -- Implementazione: `createPost` accetta `author_id`; `updatePost`/`deletePost` restano invariati ma le - route caricano il post e verificano la ownership prima di procedere (già caricano via `getPostById` - su PUT; aggiungere il caricamento su DELETE che oggi non lo fa). - -### Pannello blog (`/admin`, `/admin/new`, `/admin/edit/[id]`) -- `user` vede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore". -- Editing di un post altrui bloccato lato UI per `user` (e comunque 403 lato API). - -### Blog pubblico -- Firma autore sotto il titolo dell'articolo: nome dell'utente `author_id`; fallback - "Staff InsanityLab" quando `author_id` è nullo o orfano. - -## Gestione errori - -- API: JSON `{ error }` con status coerente (400 input non valido, 401 non autenticato — dal - middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati). -- `next` non sicuro nel login → ignorato, redirect a `/` (niente open redirect). -- Cookie `showtags` presente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side - con il ruolo reale, il cookie da solo non basta). -- Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro. - -## Testing - -- **Unit** (`tests/roles.test.ts` esteso): matrice `canAccessAdminPath` per i tre ruoli su tutti i - prefissi; `landingFor`. -- **Unit ownership**: `user` non può PUT/DELETE post altrui; `POST` imposta `author_id`; guardie - gestione utenti (no self-delete, no last-admin delete/demote). -- **Unit migrazioni**: su DB in-memory con righe `editor` e `content_blocks` senza guid → dopo - `createDb`: ruoli migrati, guid non nulli e univoci, colonna `posts.author_id` presente. -- **Smoke** (`scripts/smoke.sh` esteso): `/login` 200; header mostra "Accedi" da anonimo; rotte admin → - redirect/403 da `user`; badge con `data-guid` presenti da superuser+ con toggle; modal/badge assenti - da anonimo. - -## Fuori scope (YAGNI) - -- Nessun framework/reattività frontend (no Alpine/petite-vue). -- Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice. -- Nessuna registrazione self-service: gli utenti li crea l'admin. -- Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce. -- Nessuna gestione avatar/profilo utente oltre username + ruolo.