Files
InsanityLab-web/docs/superpowers/plans/2026-07-01-insanitylab-website.md
2026-07-01 23:31:58 +02:00

3514 lines
152 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sito InsanityLab — Piano di implementazione
> **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:** Sito vetrina InsanityLab con blog gestibile dal cliente (login + editor TipTap) e form contatti via email, pronto per porting Docker su VPS in fase 2.
**Architecture:** Astro 5 con output `server` (adapter Node standalone): pagine vetrina prerenderizzate, blog/admin server-rendered da SQLite. Un'unica app Node, JS client vanilla minimo.
**Tech Stack:** Astro 5, @astrojs/node, better-sqlite3, bcryptjs, nodemailer, sanitize-html, TipTap, @fontsource (Montserrat, Open Sans), vitest.
**Spec:** `docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
## Global Constraints
- Directory progetto: `/home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website` (repo git già inizializzato, branch `main`).
- Route pubbliche in inglese: `/`, `/about`, `/training`, `/programs`, `/programs/[slug]`, `/blog`, `/blog/[slug]`, `/contact`. Admin: `/admin`, `/admin/login`, `/admin/new`, `/admin/edit/[id]`. API: `POST /api/contact`, `/api/admin/posts*`, `POST /api/admin/upload`.
- Slug programmi: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition`.
- Categorie blog fisse: `Articoli tecnici & scientifici`, `Contenuti educativi`, `LinkedIn / Insight`, `Eventi & Presidi`.
- Palette: accento tan `#b5a48b`, marrone scuro `#3a2d26`, fondo alternativo `#f5f3f0` (verificare campionando i PDF, Task 3).
- Testi UI in italiano. Niente Google Fonts CDN (font self-hosted). Niente framework CSS o JS client.
- DB: `data/insanitylab.db` (gitignored). Upload: `uploads/` a root progetto (gitignored), serviti da route dinamica `/uploads/[...path]`.
- Variabili d'ambiente lette con fallback `process.env.X ?? import.meta.env.X` (dev: Vite carica `.env`; prod: `node --env-file=.env`).
- Sito di riferimento design: PDF in `../FigmaDoc/`. Template analizzato: `../Dati/cassetta-me-analisi.md`.
- Dati di contatto reali: tel `351 7535977`, `info@insanitylab.it`, Via Leandro Alberti 76, 40139 Bologna, LunVen 6.0021.00, Sab 8.0019.00, Dom chiuso.
- Commit frequenti, messaggi brevi in italiano, prefissi `feat:`/`fix:`/`chore:`/`test:`.
---
### Task 1: Scaffold progetto Astro
**Files:**
- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `vitest.config.ts`, `.gitignore`, `.env.example`, `README.md`, `src/pages/index.astro` (placeholder), `src/env.d.ts`, `data/.gitkeep`, `uploads/.gitkeep`
**Interfaces:**
- Produces: progetto buildabile; script npm `dev`, `build`, `preview`, `start`, `test`, `create-user`.
- [ ] **Step 1: package.json e installazione dipendenze**
Crea `package.json`:
```json
{
"name": "insanitylab-website",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node --env-file=.env ./dist/server/entry.mjs",
"test": "vitest run",
"create-user": "node scripts/create-user.mjs"
}
}
```
Run:
```bash
cd /home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website
npm install astro @astrojs/node @astrojs/sitemap better-sqlite3 bcryptjs nodemailer sanitize-html @fontsource/montserrat @fontsource/open-sans @tiptap/core @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link
npm install -D vitest typescript @types/better-sqlite3 @types/bcryptjs @types/nodemailer @types/sanitize-html
```
Expected: install senza errori (better-sqlite3 compila nativo).
- [ ] **Step 2: file di configurazione**
`astro.config.mjs`:
```js
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://insanitylab.tielogic.xyz',
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [sitemap()],
});
```
`tsconfig.json`:
```json
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
"exclude": ["dist"]
}
```
`vitest.config.ts`:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { include: ['tests/**/*.test.ts'] },
});
```
`.gitignore`:
```
node_modules/
dist/
.astro/
.env
data/*.db*
uploads/*
!uploads/.gitkeep
assets-src/
```
`.env.example`:
```
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user
SMTP_PASS=pass
CONTACT_TO=info@insanitylab.it
CONTACT_FROM=sito@insanitylab.it
DB_PATH=data/insanitylab.db
UPLOADS_DIR=uploads
```
`src/env.d.ts`:
```ts
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user?: { id: number; username: string };
}
}
```
`src/pages/index.astro` (placeholder, sostituito nel Task 9):
```astro
---
export const prerender = true;
---
<html lang="it"><head><title>InsanityLab</title></head>
<body><h1>InsanityLab — in costruzione</h1></body></html>
```
Crea `data/.gitkeep` e `uploads/.gitkeep` vuoti. Copia `.env.example` in `.env`.
- [ ] **Step 3: README.md**
Prosa italiana completa: scopo (sito vetrina InsanityLab con blog e form contatti), setup (`npm install`, `cp .env.example .env`, `npm run create-user -- <user> <pass>`), comandi (`dev`, `build`, `start`, `test`), stack, struttura cartelle, riferimento alla spec in `docs/superpowers/specs/`.
- [ ] **Step 4: verifica build**
Run: `npm run build`
Expected: build completata, cartella `dist/` creata.
- [ ] **Step 5: commit**
```bash
git add -A && git commit -m "chore: scaffold progetto Astro 5 con adapter node"
```
---
### Task 2: Estrazione asset da Figma
**Files:**
- Create: `scripts/extract-fig-images.sh`, `scripts/asset-map.json`, `scripts/apply-asset-map.mjs`, `src/assets/img/*` (immagini rinominate), `public/img/logo-*.png`
**Interfaces:**
- Produces: immagini in `src/assets/img/` con nomi semantici (`hero-1.png`, `info-performance.jpg`, `team-donata.jpg`, `program-one-to-one.jpg`, `partner-edenred.png`, ecc.); loghi in `public/img/`.
- [ ] **Step 1: script estrazione**
`scripts/extract-fig-images.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
FIG="../FigmaDoc/Web Insanity.fig"
OUT="assets-src"
rm -rf "$OUT" && mkdir -p "$OUT"
unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
cd "$OUT"
for f in *; do
case "$(file --brief --mime-type "$f")" in
image/png) mv "$f" "$f.png" ;;
image/jpeg) mv "$f" "$f.jpg" ;;
image/webp) mv "$f" "$f.webp" ;;
*) rm -f "$f" ;;
esac
done
echo "Estratte: $(ls | wc -l) immagini in $OUT/"
```
Run: `chmod +x scripts/extract-fig-images.sh && ./scripts/extract-fig-images.sh`
Expected: decine di immagini con estensione in `assets-src/`.
- [ ] **Step 2: identificazione visiva e mappatura**
Visualizza le immagini estratte (tool Read su ogni file, oppure contact sheet con `montage assets-src/*.{png,jpg} -tile 6x -geometry 200x200+4+4 -label '%f' assets-src/sheet_%d.png`). Compila `scripts/asset-map.json` associando ogni hash al nome di destinazione. Formato:
```json
{
"src/assets/img": {
"<hash>.jpg": "hero-1.jpg",
"<hash>.jpg": "info-performance.jpg",
"<hash>.jpg": "info-balance.jpg",
"<hash>.jpg": "info-longevity.jpg",
"<hash>.jpg": "feature-performance.jpg",
"<hash>.jpg": "feature-balance.jpg",
"<hash>.jpg": "feature-longevity.jpg",
"<hash>.jpg": "quote-bg.jpg",
"<hash>.jpg": "training-one-to-one.jpg",
"<hash>.jpg": "training-small-groups.jpg",
"<hash>.jpg": "training-performance-class.jpg",
"<hash>.jpg": "training-fit-remote.jpg",
"<hash>.jpg": "team-donata.jpg",
"<hash>.jpg": "team-nicola.jpg",
"<hash>.jpg": "team-eleonora.jpg",
"<hash>.jpg": "team-antonello.jpg",
"<hash>.jpg": "method-step-1.jpg",
"<hash>.jpg": "method-step-2.jpg",
"<hash>.jpg": "method-step-3.jpg",
"<hash>.jpg": "method-step-4.jpg",
"<hash>.png": "partner-edenred.png",
"<hash>.png": "partner-welbee.png",
"<hash>.png": "partner-aon.png",
"<hash>.png": "partner-howden.png"
},
"public/img": {
"<hash>.png": "logo-dark.png",
"<hash>.png": "logo-light.png"
}
}
```
Nomi aggiuntivi liberi per immagini di about/training trovate nell'archivio (`about-*.jpg`, ecc.). Le immagini non usate restano in `assets-src/` (gitignored). Se un'immagine attesa manca (es. loghi), fallback: `../Dati/logo-dark.png`, `../Dati/logo-light.png` e altri file in `../Dati/`.
- [ ] **Step 3: script applicazione mappa**
`scripts/apply-asset-map.mjs`:
```js
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
let n = 0, missing = 0;
for (const [destDir, entries] of Object.entries(map)) {
mkdirSync(join(root, destDir), { recursive: true });
for (const [src, dest] of Object.entries(entries)) {
const from = join(root, 'assets-src', src);
if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
copyFileSync(from, join(root, destDir, dest));
n++;
}
}
console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
process.exit(missing ? 1 : 0);
```
Run: `node scripts/apply-asset-map.mjs`
Expected: `Copiate N immagini`, exit 0.
- [ ] **Step 4: ottimizza pesi**
Le immagini Figma possono superare 5 MB. Ridimensiona quelle sopra 2000 px di larghezza:
```bash
cd src/assets/img
for f in *.jpg *.png; do
w=$(identify -format '%w' "$f")
[ "$w" -gt 2000 ] && mogrify -resize 2000x "$f" && echo "ridotta: $f"
done
```
Expected: nessuna immagine > 2000 px; `du -sh src/assets/img` sotto ~15 MB.
- [ ] **Step 5: commit**
```bash
git add -A && git commit -m "feat: asset immagini estratti da Figma e mappati"
```
---
### Task 3: Design tokens e CSS globale
**Files:**
- Create: `src/styles/global.css`
**Interfaces:**
- Produces: variabili CSS `--c-accent`, `--c-accent-dark`, `--c-dark`, `--c-text`, `--c-heading`, `--c-bg-alt`; classi utility `.container`, `.btn`, `.btn--dark`, `.btn--light`, `.section`, `.section--dark`, `.section-heading`, `.eyebrow`; font family var `--font-heading`, `--font-body`.
- [ ] **Step 1: campiona colori esatti dai PDF**
```bash
S=/tmp/ilab-sample && mkdir -p $S
pdftoppm -png -r 30 "../FigmaDoc/Homepage.pdf" $S/h
# bottone SCOPRI hero (~x55,y200 alla risoluzione 30dpi), sezione agenda scura, sfondo chiaro
convert $S/h-1.png -format "%[pixel:p{60,205}] %[pixel:p{50,1500}] %[pixel:p{300,1360}]" info:
```
Annota i valori RGB reali e usali negli step successivi al posto dei default indicati (attesi: tan ~`#b5a48b`, marrone ~`#3a2d26`, chiaro ~`#f5f3f0`).
- [ ] **Step 2: global.css**
```css
/* === Design tokens (valori campionati da Figma) === */
:root {
--c-accent: #b5a48b;
--c-accent-dark: #9c8b70;
--c-dark: #3a2d26;
--c-dark-2: #4a3a30;
--c-heading: #2b2b2b;
--c-text: #4b4b4b;
--c-text-light: #8a8a8a;
--c-bg: #ffffff;
--c-bg-alt: #f5f3f0;
--font-heading: 'Montserrat', sans-serif;
--font-body: 'Open Sans', sans-serif;
--header-h: 70px;
}
/* === Reset === */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: var(--font-body); color: var(--c-text); font-size: 16px; line-height: 1.7; background: var(--c-bg); }
img { max-width: 100%; height: auto; display: block; }
h1, h2, h3, h4 { font-family: var(--font-heading); color: var(--c-heading); font-weight: 500; letter-spacing: .04em; line-height: 1.2; margin: 0 0 .5em; }
h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); }
h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
h3 { font-size: 1.3rem; }
a { color: inherit; }
p { margin: 0 0 1em; }
/* === Utility === */
.container { max-width: 1200px; margin-inline: auto; padding-inline: 20px; }
.section { padding-block: 90px; }
.section--alt { background: var(--c-bg-alt); }
.section--dark { background: var(--c-dark); color: #cfc6bd; }
.section--dark h2, .section--dark h3 { color: #fff; }
.eyebrow { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .25em; font-size: .75rem; color: var(--c-accent-dark); }
.section-heading { text-align: center; max-width: 640px; margin: 0 auto 56px; }
.section-heading h2 { text-transform: uppercase; letter-spacing: .12em; }
.btn { display: inline-flex; align-items: center; gap: 14px; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; cursor: pointer; padding: 16px 26px; background: var(--c-accent); color: #fff; transition: background .2s; }
.btn:hover { background: var(--c-accent-dark); }
.btn::after { content: '→'; font-size: .9rem; }
.btn--dark { background: var(--c-dark); }
.btn--dark:hover { background: var(--c-dark-2); }
.btn--light { background: #fff; color: var(--c-heading); }
/* === Form === */
.field { width: 100%; border: 1px solid #ddd; background: #fff; padding: 14px 16px; font-family: var(--font-body); font-size: .95rem; color: var(--c-text); margin-bottom: 14px; }
.field:focus { outline: 2px solid var(--c-accent); outline-offset: -1px; }
textarea.field { min-height: 120px; resize: vertical; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
/* honeypot */
.hp { position: absolute; left: -9999px; opacity: 0; }
```
- [ ] **Step 2b: aggiorna i valori con quelli campionati allo Step 1.**
- [ ] **Step 3: commit**
```bash
git add src/styles/global.css && git commit -m "feat: design tokens e css globale"
```
---
### Task 4: Lib di base — slugify e rate limit (TDD)
**Files:**
- Create: `src/lib/slug.ts`, `src/lib/rate-limit.ts`, `src/lib/env.ts`
- Test: `tests/slug.test.ts`, `tests/rate-limit.test.ts`
**Interfaces:**
- Produces: `slugify(input: string): string`; `rateLimit(key: string, max: number, windowMs: number): boolean` (true = consentito); `getEnv(name: string, fallback?: string): string`.
- [ ] **Step 1: test slugify (falliranno)**
`tests/slug.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { slugify } from '../src/lib/slug';
describe('slugify', () => {
it('minuscole e trattini', () => {
expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab');
});
it('rimuove accenti', () => {
expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui');
});
it('comprime simboli e spazi multipli', () => {
expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news');
});
it('niente trattini ai bordi', () => {
expect(slugify(' ciao ')).toBe('ciao');
});
it('stringa vuota resta vuota', () => {
expect(slugify('')).toBe('');
});
});
```
- [ ] **Step 2: verifica che falliscano**
Run: `npx vitest run tests/slug.test.ts`
Expected: FAIL (`slugify` non esiste).
- [ ] **Step 3: implementa slugify**
`src/lib/slug.ts`:
```ts
export function slugify(input: string): string {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
```
- [ ] **Step 4: test rate limit (falliranno)**
`tests/rate-limit.test.ts`:
```ts
import { describe, it, expect, vi, afterEach } from 'vitest';
import { rateLimit, _resetBuckets } from '../src/lib/rate-limit';
afterEach(() => { _resetBuckets(); vi.useRealTimers(); });
describe('rateLimit', () => {
it('consente fino a max richieste', () => {
expect(rateLimit('a', 2, 1000)).toBe(true);
expect(rateLimit('a', 2, 1000)).toBe(true);
expect(rateLimit('a', 2, 1000)).toBe(false);
});
it('chiavi indipendenti', () => {
expect(rateLimit('a', 1, 1000)).toBe(true);
expect(rateLimit('b', 1, 1000)).toBe(true);
});
it('la finestra scade', () => {
vi.useFakeTimers();
expect(rateLimit('a', 1, 1000)).toBe(true);
expect(rateLimit('a', 1, 1000)).toBe(false);
vi.advanceTimersByTime(1100);
expect(rateLimit('a', 1, 1000)).toBe(true);
});
});
```
- [ ] **Step 5: implementa rate-limit ed env**
`src/lib/rate-limit.ts`:
```ts
const buckets = new Map<string, number[]>();
export function rateLimit(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
const recent = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= max) {
buckets.set(key, recent);
return false;
}
recent.push(now);
buckets.set(key, recent);
return true;
}
export function _resetBuckets(): void {
buckets.clear();
}
```
`src/lib/env.ts`:
```ts
export function getEnv(name: string, fallback?: string): string {
const v = process.env[name] ?? (import.meta.env ? import.meta.env[name] : undefined) ?? fallback;
if (v === undefined) throw new Error(`Variabile d'ambiente mancante: ${name}`);
return v;
}
```
- [ ] **Step 6: verifica test verdi**
Run: `npm test`
Expected: tutti PASS.
- [ ] **Step 7: commit**
```bash
git add src/lib tests && git commit -m "feat: slugify, rate limit ed env helper con test"
```
---
### Task 5: Layer database — schema e repository posts (TDD)
**Files:**
- Create: `src/lib/db.ts`, `src/lib/posts.ts`
- Test: `tests/posts.test.ts`
**Interfaces:**
- Consumes: `slugify` da Task 4 (nei consumer, non qui).
- Produces:
- `createDb(path?: string): Database.Database` — apre/crea DB, applica schema idempotente.
- `getDb(): Database.Database` — singleton su `DB_PATH`.
- Tipo `Post { id: number; title: string; slug: string; category: string; excerpt: string; body_html: string; cover: string | null; draft: number; published_at: string | null; created_at: string; updated_at: string }`.
- `PostInput { title: string; slug: string; category: string; excerpt: string; body_html: string; cover?: string | null; draft: boolean }`.
- `createPost(db, input: PostInput): number` (ritorna id; se `draft=false` imposta `published_at`).
- `updatePost(db, id: number, input: PostInput): void` (imposta `published_at` alla prima pubblicazione, aggiorna `updated_at`).
- `deletePost(db, id: number): void`
- `getPostById(db, id: number): Post | undefined`
- `getPublishedBySlug(db, slug: string): Post | undefined`
- `listPublished(db, opts?: { category?: string; page?: number; perPage?: number }): { items: Post[]; total: number }` (ordinati per `published_at` desc)
- `listAllPosts(db): Post[]` (anche bozze, per admin)
- `listRecent(db, limit?: number): Post[]`
- `listArchiveMonths(db): { month: string; count: number }[]` (formato `YYYY-MM`)
- [ ] **Step 1: implementa db.ts**
`src/lib/db.ts`:
```ts
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
cover TEXT,
draft INTEGER NOT NULL DEFAULT 1,
published_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
`;
export function createDb(path?: string): Database.Database {
const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
let singleton: Database.Database | null = null;
export function getDb(): Database.Database {
if (!singleton) singleton = createDb();
return singleton;
}
```
- [ ] **Step 2: test posts (falliranno)**
`tests/posts.test.ts`:
```ts
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import {
createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
} from '../src/lib/posts';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
const base: PostInput = {
title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
};
describe('posts repository', () => {
it('crea e rilegge un post pubblicato', () => {
const id = createPost(db, base);
const post = getPostById(db, id)!;
expect(post.title).toBe('Titolo');
expect(post.draft).toBe(0);
expect(post.published_at).not.toBeNull();
});
it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
const id = createPost(db, { ...base, slug: 'bozza', draft: true });
expect(getPostById(db, id)!.published_at).toBeNull();
expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
expect(listPublished(db).total).toBe(0);
expect(listAllPosts(db)).toHaveLength(1);
});
it('slug duplicato lancia', () => {
createPost(db, base);
expect(() => createPost(db, base)).toThrow();
});
it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
const id = createPost(db, { ...base, draft: true });
updatePost(db, id, { ...base, draft: false });
const first = getPostById(db, id)!.published_at;
expect(first).not.toBeNull();
updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
expect(getPostById(db, id)!.published_at).toBe(first);
expect(getPostById(db, id)!.title).toBe('Nuovo');
});
it('listPublished filtra per categoria e pagina', () => {
for (let i = 0; i < 12; i++) {
createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
}
const all = listPublished(db, { page: 1, perPage: 9 });
expect(all.total).toBe(12);
expect(all.items).toHaveLength(9);
const edu = listPublished(db, { category: 'Contenuti educativi' });
expect(edu.total).toBe(6);
});
it('delete rimuove', () => {
const id = createPost(db, base);
deletePost(db, id);
expect(getPostById(db, id)).toBeUndefined();
});
it('listRecent e archivio mesi', () => {
createPost(db, base);
expect(listRecent(db, 4)).toHaveLength(1);
const months = listArchiveMonths(db);
expect(months).toHaveLength(1);
expect(months[0].count).toBe(1);
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
});
});
```
- [ ] **Step 3: verifica che falliscano**
Run: `npx vitest run tests/posts.test.ts`
Expected: FAIL (modulo `posts` mancante).
- [ ] **Step 4: implementa posts.ts**
`src/lib/posts.ts`:
```ts
import type Database from 'better-sqlite3';
export interface Post {
id: number; title: string; slug: string; category: string; excerpt: string;
body_html: string; cover: string | null; draft: number;
published_at: string | null; created_at: string; updated_at: string;
}
export interface PostInput {
title: string; slug: string; category: string; excerpt: string;
body_html: string; cover?: string | null; draft: boolean;
}
export function createPost(db: Database.Database, input: PostInput): number {
const res = db.prepare(
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
return Number(res.lastInsertRowid);
}
export function updatePost(db: Database.Database, id: number, input: PostInput): void {
db.prepare(
`UPDATE posts SET title = @title, slug = @slug, category = @category,
excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft,
published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END,
updated_at = datetime('now')
WHERE id = @id`
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
}
export function deletePost(db: Database.Database, id: number): void {
db.prepare('DELETE FROM posts WHERE id = ?').run(id);
}
export function getPostById(db: Database.Database, id: number): Post | undefined {
return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined;
}
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
}
export function listPublished(
db: Database.Database,
opts: { category?: string; page?: number; perPage?: number } = {},
): { items: Post[]; total: number } {
const { category, page = 1, perPage = 9 } = opts;
const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
.get({ category }) as { n: number }).n;
const items = db.prepare(
`SELECT * FROM posts ${where} ORDER BY published_at DESC LIMIT @limit OFFSET @offset`
).all({ category, limit: perPage, offset: (page - 1) * perPage }) as Post[];
return { items, total };
}
export function listAllPosts(db: Database.Database): Post[] {
return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
}
export function listRecent(db: Database.Database, limit = 4): Post[] {
return db.prepare(
'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
).all(limit) as Post[];
}
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
return db.prepare(
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
).all() as { month: string; count: number }[];
}
```
- [ ] **Step 5: verifica test verdi**
Run: `npm test`
Expected: tutti PASS.
- [ ] **Step 6: commit**
```bash
git add src/lib/db.ts src/lib/posts.ts tests/posts.test.ts && git commit -m "feat: layer sqlite e repository posts con test"
```
---
### Task 6: Autenticazione, middleware e script create-user (TDD)
**Files:**
- Create: `src/lib/auth.ts`, `src/middleware.ts`, `scripts/create-user.mjs`
- Test: `tests/auth.test.ts`
**Interfaces:**
- Consumes: `createDb`/`getDb` (Task 5), `rateLimit` (Task 4).
- Produces:
- `hashPassword(plain: string): string`, `verifyPassword(plain: string, hash: string): boolean`
- `createUser(db, username: string, password: string): number`
- `login(db, username: string, password: string): string | null` — token sessione o null.
- `getSessionUser(db, token: string): { id: number; username: string } | null` — null se scaduta (e la elimina).
- `logout(db, token: string): void`
- Costante `SESSION_COOKIE = 'session'`, durata 7 giorni.
- Middleware: protegge `/admin*` (tranne `/admin/login`) e `/api/admin*`; popola `Astro.locals.user`.
- [ ] **Step 1: test auth (falliranno)**
`tests/auth.test.ts`:
```ts
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('auth', () => {
it('hash e verifica password', () => {
const h = hashPassword('segreta123');
expect(h).not.toBe('segreta123');
expect(verifyPassword('segreta123', h)).toBe(true);
expect(verifyPassword('sbagliata', h)).toBe(false);
});
it('login corretto crea sessione recuperabile', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123');
expect(token).toBeTruthy();
const user = getSessionUser(db, token!);
expect(user).toMatchObject({ username: 'adriano' });
});
it('login errato ritorna null', () => {
createUser(db, 'adriano', 'segreta123');
expect(login(db, 'adriano', 'sbagliata')).toBeNull();
expect(login(db, 'inesistente', 'x')).toBeNull();
});
it('sessione scaduta viene rifiutata ed eliminata', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
expect(getSessionUser(db, token)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
});
it('logout elimina la sessione', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
logout(db, token);
expect(getSessionUser(db, token)).toBeNull();
});
});
```
- [ ] **Step 2: verifica che falliscano**
Run: `npx vitest run tests/auth.test.ts`
Expected: FAIL (modulo `auth` mancante).
- [ ] **Step 3: implementa auth.ts**
`src/lib/auth.ts`:
```ts
import type Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7;
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12);
}
export function verifyPassword(plain: string, hash: string): boolean {
return bcrypt.compareSync(plain, hash);
}
export function createUser(db: Database.Database, username: string, password: string): number {
const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, hashPassword(password));
return Number(res.lastInsertRowid);
}
export function login(db: Database.Database, username: string, password: string): string | null {
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
| { id: number; password_hash: string } | undefined;
if (!user || !verifyPassword(password, user.password_hash)) return null;
const token = randomBytes(32).toString('hex');
db.prepare(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`
).run(token, user.id);
return token;
}
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null {
const row = db.prepare(
`SELECT s.token, s.expires_at, u.id, u.username
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined;
if (!row) return null;
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
return null;
}
return { id: row.id, username: row.username };
}
export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
```
- [ ] **Step 4: verifica test verdi**
Run: `npm test`
Expected: tutti PASS.
- [ ] **Step 5: middleware**
`src/middleware.ts`:
```ts
import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url;
const isProtected =
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
pathname.startsWith('/api/admin');
if (!isProtected) return next();
const token = context.cookies.get(SESSION_COOKIE)?.value;
const user = token ? getSessionUser(getDb(), token) : null;
if (!user) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Non autorizzato' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
return context.redirect('/admin/login');
}
context.locals.user = user;
return next();
});
```
- [ ] **Step 6: script create-user**
`scripts/create-user.mjs`:
```js
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const [, , username, password] = process.argv;
if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password>');
process.exit(1);
}
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);`);
db.prepare(
`INSERT INTO users (username, password_hash) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
).run(username, bcrypt.hashSync(password, 12));
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
```
Run: `npm run create-user -- admin prova1234 && sqlite3 data/insanitylab.db 'SELECT username FROM users;'`
Expected: `admin`.
- [ ] **Step 7: commit**
```bash
git add src/lib/auth.ts src/middleware.ts scripts/create-user.mjs tests/auth.test.ts
git commit -m "feat: autenticazione a sessioni, middleware admin e script create-user"
```
---
### Task 7: Layout base, Header, Footer, 404
**Files:**
- Create: `src/layouts/Base.astro`, `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro`, `src/pages/404.astro`
- Modify: `src/pages/index.astro` (usa Base)
- Depends: `src/data/site.ts` (Task 8 — eseguire Task 8 prima o creare qui il file e ampliarlo lì; ordine consigliato: Task 8 poi Task 7 se si preferisce, sono indipendenti dagli altri)
**Interfaces:**
- Consumes: `site` da `src/data/site.ts` (Task 8): `{ name, phone, email, address, cityLine, hours: string[], socials: { label, url }[], navigation: { label, href }[] }`.
- Produces: `<Base title description>` layout con slot; `<ContactForm variant="full" | "footer" />` che POSTa a `/api/contact` via fetch.
- [ ] **Step 1: Base.astro**
```astro
---
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/montserrat/700.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title>
<meta name="description" content={description} />
<meta property="og:title" content={`${title} — InsanityLab`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap-index.xml" />
</head>
<body>
<Header />
<main><slot /></main>
<Footer />
</body>
</html>
```
- [ ] **Step 2: Header.astro**
Nav da `site.navigation`: Insanitylab→`/about`, Training→`/training`, Programmi→`/programs`, Blog & Edugo→`/blog`, Contatti→`/contact`. Logo `/img/logo-dark.png` a sinistra, link `/`.
```astro
---
import { site } from '../data/site';
const path = Astro.url.pathname;
---
<header class="hdr" id="site-header">
<div class="container hdr__in">
<a href="/" class="hdr__logo"><img src="/img/logo-dark.png" alt="InsanityLab" width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a>
))}
</nav>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</header>
<style>
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
.hdr__nav { display: flex; gap: 34px; }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) {
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
.hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; }
.hdr__burger { display: block; }
}
</style>
<script>
const header = document.getElementById('site-header')!;
const toggle = document.getElementById('nav-toggle')!;
const nav = document.getElementById('site-nav')!;
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
toggle.addEventListener('click', () => {
const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
</script>
```
- [ ] **Step 3: ContactForm.astro**
```astro
---
interface Props { variant?: 'full' | 'footer' }
const { variant = 'full' } = Astro.props;
---
<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate>
<input class="field" name="firstName" placeholder="Nome" required maxlength="100" />
{variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />}
{variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />}
<input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" />
<textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea>
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
<button class="btn" type="submit">Invia messaggio</button>
<p class="form-msg" hidden></p>
</form>
<script>
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
const btn = form.querySelector<HTMLButtonElement>('button')!;
btn.disabled = true;
msg.hidden = true;
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const data = await res.json();
msg.textContent = res.ok ? 'Messaggio inviato, ti ricontatteremo al più presto.' : (data.error ?? 'Errore di invio, riprova più tardi.');
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
if (res.ok) form.reset();
} catch {
msg.textContent = 'Errore di rete, riprova più tardi.';
msg.className = 'form-msg form-msg--err';
}
msg.hidden = false;
btn.disabled = false;
});
});
</script>
```
- [ ] **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/<slug>`), colonna CONTACT con `<ContactForm variant="footer" />`. 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();
---
<footer class="ftr">
<div class="container ftr__grid">
<div>
<img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" />
<p class="ftr__tag">PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.</p>
<p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p>
<p>{site.phone}<br />{site.address}, {site.cityLine}</p>
{site.hours.map((h) => <p class="ftr__hours">{h}</p>)}
</div>
<nav aria-label="training">
<h4>Training</h4>
{site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)}
</nav>
<nav aria-label="programmi">
<h4>Programmi</h4>
{site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)}
</nav>
<div>
<h4>Contact</h4>
<ContactForm variant="footer" />
</div>
</div>
<div class="ftr__bar">
<div class="container ftr__bar-in">
<p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p>
<p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div>
</div>
</footer>
<style>
.ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
.ftr a:hover { color: #fff; }
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
.ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
.ftr__hours { margin: 0; }
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; }
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
</style>
```
- [ ] **Step 5: 404.astro**
```astro
---
import Base from '../layouts/Base.astro';
export const prerender = true;
---
<Base title="Pagina non trovata">
<section class="section" style="text-align:center; padding-block:140px;">
<div class="container">
<p class="eyebrow">Errore 404</p>
<h1>Pagina non trovata</h1>
<p>La pagina che cerchi non esiste o è stata spostata.</p>
<a class="btn" href="/">Torna alla home</a>
</div>
</section>
</Base>
```
- [ ] **Step 6: aggiorna index placeholder per usare Base**
```astro
---
import Base from '../layouts/Base.astro';
export const prerender = true;
---
<Base title="Performance. Balance. Longevity.">
<section class="section"><div class="container"><h1>Homepage in costruzione</h1></div></section>
</Base>
```
- [ ] **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 lanamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto.' },
{ q: 'Con quale frequenza ci si allena?', a: 'La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza.' },
{ q: 'È adatto a chi parte da zero?', a: 'Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante.' },
],
hasPricing: false,
},
{
slug: 'performance-class',
title: 'Performance Class',
subtitle: 'Gruppo max 8 persone',
excerpt: 'La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.',
longDescription: 'La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso allinterno di un piano più ampio.',
features: ['Allenamento ibrido', 'Forza, ipertrofia, condizionamento metabolico e lavoro funzionale', 'Sessioni da 50 minuti', 'Fasi di bassa, media e alta intensità', 'Programmazione mensile'],
faq: [
{ q: 'Che cosa si intende per Performance Class?', a: 'Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso allinterno di un piano più ampio.' },
{ q: 'Che tipo di allenamento viene svolto?', a: 'Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile.' },
{ q: 'A chi è consigliato questo servizio?', a: 'A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile.' },
{ q: 'Qual è la differenza rispetto a una class tradizionale?', a: 'La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati.' },
{ q: 'Che tipo di risultati o miglioramenti si ottengono?', a: 'Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo.' },
{ q: 'Qual è lintensità del lavoro?', a: 'Alternata: fasi di bassa, media e alta intensità allinterno della stessa sessione, adattate al livello dei partecipanti.' },
],
hasPricing: true,
},
{
slug: 'coaching',
title: 'Coaching',
subtitle: 'Programma di allenamento e monitoraggio',
excerpt: 'Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nellallenamento.',
longDescription: 'Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).',
features: ['Programma su misura', 'Supporto e monitoraggio costanti', 'Ovunque ti alleni (Fit Remote)', 'Sviluppo di autonomia'],
faq: [
{ q: 'Come funziona il monitoraggio?', a: 'Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati.' },
{ q: 'Serve attrezzatura?', a: 'Il programma viene costruito sullattrezzatura che hai a disposizione, in palestra o a casa.' },
{ q: 'È adatto se viaggio spesso?', a: 'Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi.' },
],
hasPricing: false,
},
{
slug: 'pilates-flow',
title: 'Pilates Flow',
subtitle: 'Movimento e controllo',
excerpt: 'Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre losteopatia supporta recupero, prevenzione e benessere globale integrandosi con lallenamento.',
longDescription: 'Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con losteopatia, supporta recupero, prevenzione e benessere globale allinterno del percorso di allenamento.',
features: ['Postura e core', 'Movimento fluido e controllo', 'Integrazione con osteopatia', 'Benessere globale'],
faq: [
{ q: 'A chi è adatto il Pilates Flow?', a: 'A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello.' },
{ q: 'Serve esperienza precedente?', a: 'No: le progressioni sono graduali e seguite da professionisti qualificati.' },
{ q: 'Come si integra con losteopatia?', a: 'Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento.' },
],
hasPricing: false,
},
{
slug: 'rehab',
title: 'Rehab',
subtitle: 'Recupero post-infortunio, patologie muscolo-scheletriche',
excerpt: 'Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno allattività fisica in modo sicuro, progressivo e personalizzato.',
longDescription: 'Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno allattività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.',
features: ['Recupero post-infortunio', 'Patologie muscolo-scheletriche', 'Supervisione di chinesiologi e osteopati', 'Progressione sicura e personalizzata'],
faq: [
{ q: 'Chi segue il percorso Rehab?', a: 'Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con larea osteopatica.' },
{ q: 'Serve una diagnosi medica?', a: 'Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari.' },
{ q: 'Quanto dura il percorso?', a: 'Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso.' },
],
hasPricing: false,
},
{
slug: 'nutrition',
title: 'Nutrizione',
subtitle: 'Alimentazione e stile di vita',
excerpt: 'La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.',
longDescription: 'Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.',
features: ['Piani alimentari su misura', 'Integrazione con lallenamento', 'Sostenibilità nel tempo', 'Focus su energia e performance'],
faq: [
{ q: 'Come inizia il percorso di nutrizione?', a: 'Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato.' },
{ q: 'È integrato con il training?', a: 'Sì: alimentazione e allenamento vengono programmati in modo coerente.' },
{ q: 'Sono previsti controlli periodici?', a: 'Sì, con aggiornamento del piano in base ai risultati.' },
],
hasPricing: false,
},
];
export function getProgram(slug: string): Program | undefined {
return programs.find((p) => p.slug === slug);
}
```
- [ ] **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. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.', cta: { label: 'Contattaci', href: '/contact' } },
{ id: '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 larea 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 allallenamento 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 lascolto 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 allequilibrio 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 dellallenamento 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 unanamnesi 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 unesperienza 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: `<Agenda />` riusato dal Task 11; `<TrainingCards />` 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' },
];
---
<section class="hero" aria-label="presentazione">
{slides.map((s, i) => (
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
<div class="container hero__in">
<div class="hero__text">
<h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
<a class="btn" href="/programs">Scopri</a>
</div>
<Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
</div>
</div>
))}
{slides.length > 1 && (
<>
<button class="hero__arrow hero__arrow--prev" aria-label="precedente"></button>
<button class="hero__arrow hero__arrow--next" aria-label="successiva"></button>
</>
)}
</section>
<style>
.hero { position: relative; background:
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
.hero__slide { display: none; }
.hero__slide.is-active { display: block; }
.hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
.hero__line { display: block; }
.hero__text h1 { font-weight: 500; }
.hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); }
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
@media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
</style>
<script>
const slides = document.querySelectorAll('.hero__slide');
if (slides.length > 1) {
let cur = 0;
const show = (i: number) => {
slides[cur].classList.remove('is-active');
cur = (i + slides.length) % slides.length;
slides[cur].classList.add('is-active');
};
document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
}
</script>
```
- [ ] **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 },
];
---
<section class="info" aria-label="pilastri">
{cols.map((c) => (
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}>
<span class="info__title">{c.title}</span>
</a>
))}
</section>
<style>
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
.info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
.info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
.info__col:hover::before { background: rgba(30,24,20,.15); }
.info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
</style>
```
- [ ] **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: 'Lequilibrio 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.' },
];
---
<section class="section">
<div class="container">
{blocks.map((b) => (
<div class:list={['feat', { 'feat--reverse': b.reverse }]}>
<Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" />
<div class="feat__text">
<h2>{b.title}</h2>
<p>{b.text}</p>
<a class="btn" href="/programs">Scopri</a>
</div>
</div>
))}
</div>
</section>
<style>
.feat { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; }
.feat--reverse .feat__img { order: 2; }
.feat__text h2 { text-transform: uppercase; letter-spacing: .1em; }
@media (max-width: 767px) { .feat { grid-template-columns: 1fr; gap: 24px; } .feat--reverse .feat__img { order: 0; } }
</style>
```
- [ ] **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';
---
<section class="quote" style={`background-image:url(${quoteBg.src})`}>
<div class="container quote__in">
<p class="quote__mark">“</p>
<blockquote>
<p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p>
<cite>Insanitylab</cite>
</blockquote>
</div>
</section>
<style>
.quote { position: relative; background-size: cover; background-position: center; text-align: center; }
.quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
.quote__in { position: relative; padding-block: 110px; max-width: 820px; }
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
.quote blockquote { margin: 0; }
.quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
.quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
</style>
```
- [ ] **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'];
---
<section class="tcards">
{trainings.map((t, i) => (
<>
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`, { 'tcards__cell--swap': i % 2 === 1 }]}>
<h3>{t.title}</h3>
<p>{t.caption}</p>
<a class="tcards__link" href={t.cta.href}>Scopri →</a>
</div>
{imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />}
</>
))}
</section>
<style>
.tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
.tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; }
.tcards__cell--dark { background: var(--c-dark); }
.tcards__cell--dark h3, .tcards__cell--dark p { color: #efe9e3; }
.tcards__cell--tan { background: var(--c-accent); }
.tcards__cell--tan h3, .tcards__cell--tan p { color: #fff; }
.tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
.tcards__cell p { font-size: .88rem; }
.tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; }
/* alterna ordine nella riga 2: foto prima della card */
.tcards__cell--swap { order: 1; }
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } .tcards__cell--swap { order: 0; } }
</style>
```
(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<string, string> = {
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
};
---
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Programmi</h2>
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.</p>
</div>
<div class="pgrid">
{programs.map((p) => (
<a class="pgrid__item" href={`/programs/${p.slug}`}>
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
<h3>{p.title}</h3>
<p>{p.subtitle}</p>
</a>
))}
</div>
</div>
</section>
<style>
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
.pgrid__item { text-decoration: none; }
.pgrid__item svg { margin-inline: auto; }
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
</style>
```
(Se nell'archivio Figma sono presenti le icone originali dei programmi, usale al posto degli SVG inline: mappale nel Task 2 come `icon-<slug>.png`.)
- [ ] **Step 7: Agenda.astro (condiviso)**
Sezione scura con tab discipline e griglia settimanale:
```astro
---
import { agenda, disciplines, days, slots } from '../data/agenda';
---
<section class="section section--dark agenda">
<div class="container">
<div class="section-heading"><h2>Agenda</h2></div>
<div class="agenda__tabs" role="tablist">
{disciplines.map((d, i) => (
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
))}
</div>
<div class="agenda__grid">
<div class="agenda__corner"></div>
{days.map((d) => <div class="agenda__day">{d}</div>)}
{slots.map((slot) => (
<>
<div class="agenda__slot">{slot}</div>
{days.map((_, dayIdx) => {
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
return entry
? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
: <div class="agenda__cell agenda__cell--empty"></div>;
})}
</>
))}
</div>
</div>
</section>
<style>
.agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
.agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
.agenda__tab.is-active { background: var(--c-accent); color: #fff; }
.agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
.agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
.agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
.agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
.agenda__cell em { font-style: normal; opacity: .85; }
.agenda__cell--empty { background: transparent; }
.agenda__cell.is-dim { opacity: .15; }
@media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
</style>
<script>
document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
tabs.forEach((tab) => tab.addEventListener('click', () => {
tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
const f = tab.dataset.filter!;
cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
}));
});
</script>
```
- [ ] **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;
---
<section class="section">
<div class="container">
<div class="tteam__head">
<h2>Team<br />Trainer</h2>
<a class="btn" href="/about">Scopri team</a>
</div>
<div class="tteam__grid">
{members.map((m) => (
<figure class="tteam__card">
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
<figcaption>{m.shortName}</figcaption>
</figure>
))}
</div>
</div>
</section>
<style>
.tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
.tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
.tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
.tteam__card { position: relative; margin: 0; }
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; }
.tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
@media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
</style>
```
`MethodSteps.astro` — intestazione "IL METODO INSANITYLAB" + 4 cerchi numerati con linea tratteggiata; nota extra sopra gli step 34:
```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;
---
<section class="section section--alt">
<div class="container">
<div class="section-heading">
<h2>Il metodo<br />InsanityLab</h2>
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
</div>
<p class="method__note">{methodExtraNote}</p>
<div class="method__row">
{methodSteps.map((s) => (
<div class:list={['method__step', { 'method__step--extra': s.extra }]}>
<div class="method__circle">
{imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
<span>{s.n}</span>
</div>
<h3>{s.title}</h3>
<p>{s.text}</p>
</div>
))}
</div>
</div>
</section>
<style>
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
.method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
.method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
.method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
.method__step p { font-size: .85rem; color: var(--c-text-light); }
@media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } }
</style>
```
`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;
---
<section class="pstrip" aria-label="partner">
<div class="container pstrip__in">
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
</div>
</section>
<style>
.pstrip { background: var(--c-accent); padding-block: 40px; }
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
.pstrip img { height: 44px; width: auto; opacity: .9; }
</style>
```
- [ ] **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;
---
<Base title="Performance. Balance. Longevity.">
<HeroSlider />
<InfoColumns />
<FeatureBlocks />
<QuoteBanner />
<section class="section" style="padding-bottom:0">
<div class="section-heading">
<h2>Training</h2>
<p>Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.</p>
</div>
</section>
<TrainingCards />
<ProgramsGrid />
<Agenda />
<TeamSection />
<MethodSteps />
<PartnersStrip />
</Base>
```
- [ ] **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: `<PageHero title />` 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;
---
<section class="phero">
<div class="container">
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
</div>
</section>
<style>
.phero { background: var(--c-bg-alt); padding-block: 70px; }
.phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
</style>
```
- [ ] **Step 2: Timeline.astro (Storylab)**
Blocco 2021-2024 aperto con testo; 2025, 2026 e OGGI come accordion `<details>`:
```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 larea dedicata alla riabilitazione e allesercizio 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.' },
];
---
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Storylab</h2>
<p>Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.</p>
</div>
<div class="tl">
<h3 class="tl__label">INSANITYLAB 2021-2024</h3>
<p>{openText}</p>
{stages.map((s) => (
<details class="tl__stage">
<summary>+ {s.label}</summary>
<p>{s.text}</p>
</details>
))}
</div>
</div>
</section>
<style>
.tl { max-width: 720px; margin-inline: auto; }
.tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
.tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
.tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
.tl__stage summary::-webkit-details-marker { display: none; }
</style>
```
- [ ] **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;
---
<Base title="Insanitylab" description="Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.">
<PageHero title="Insanitylab" />
<section class="section">
<div class="container section-heading">
<h2>Mission</h2>
<p>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.</p>
</div>
</section>
<section class="section section--alt">
<div class="container about-2col">
<div>
<h2>Direzione</h2>
<p>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.</p>
<a class="btn" href="/programs">Scopri</a>
</div>
<div>
<h2>Approccio</h2>
<p>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.</p>
<a class="btn btn--dark" href="/contact">Contattaci</a>
</div>
</div>
</section>
<section class="section">
<div class="container section-heading">
<h2>About</h2>
<p>InsanityLab nasce con lobiettivo 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 allinterno di un approccio personalizzato e consapevole.</p>
</div>
</section>
<Timeline />
<section class="section section--dark">
<div class="container">
<div class="section-heading">
<h2>Convenzioni</h2>
<p>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 lobiettivo di portare il valore del benessere anche allinterno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana.</p>
</div>
<ul class="conv">
<li>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.</li>
<li>Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.</li>
<li>Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dellazienda partner.</li>
</ul>
<p class="conv__cta">Per ricevere maggiori informazioni o attivare una convenzione: {site.email} · {site.phone}</p>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Team</h2>
<p>Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.</p>
</div>
<div class="tgrid tgrid--founders">
{founders.map((m) => (
<article class="tcard">
{m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
<h3>{m.shortName}</h3>
<p class="tcard__role">{m.role}</p>
<p>{m.bio}</p>
</article>
))}
</div>
<p class="section-heading" style="margin-top:60px">Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:</p>
<div class="tgrid">
{staff.map((m) => (
<article class="tcard">
{m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
<h3>{m.shortName}</h3>
<p class="tcard__role">{m.role}</p>
<p>{m.bio}</p>
</article>
))}
</div>
</div>
</section>
<section class="section section--alt">
<div class="container section-heading">
<h2>Metodo</h2>
<p>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.</p>
<p>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.</p>
</div>
</section>
<section class="section">
<div class="container section-heading">
<h2>In-Sanity Studios</h2>
<p>IN-SANITY STUDIOS è il nuovo spazio professionale nato dallevoluzione 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.</p>
<p>Il progetto nasce con lobiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività allinterno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente.</p>
<p>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, allinterno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.</p>
</div>
</section>
<section class="section section--alt">
<div class="container section-heading">
<h2>Vision</h2>
<p>InsanityLab nasce con lobiettivo 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 lestetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita.</p>
<p>Lobiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità.</p>
</div>
</section>
</Base>
<style>
.about-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; }
.conv { max-width: 760px; margin: 0 auto 30px; display: grid; gap: 18px; padding-left: 20px; }
.conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
.tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
.tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; }
.tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
.tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
.tcard p { font-size: .9rem; }
@media (max-width: 767px) { .about-2col, .tgrid { grid-template-columns: 1fr; } }
</style>
```
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), `<TrainingCards />`, `<Agenda />` (Task 9), `<PageHero />` (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;
---
<Base title="Training" description="One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.">
<PageHero title="training" />
<section class="section" style="padding-bottom:0">
<div class="section-heading">
<h2>Training</h2>
<p>Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.</p>
</div>
</section>
<TrainingCards />
<section class="section">
<div class="container">
{trainings.map((t, i) => (
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}>
{imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" />}
<div>
<h2>{t.title.toLowerCase()}</h2>
<p>{t.description}</p>
<a class="btn" href={t.cta.href}>{t.cta.label}</a>
</div>
</div>
))}
</div>
</section>
<Agenda />
<section class="section section--alt">
<div class="container trial">
<div>
<h2>Prenota la lezione di prova</h2>
<p>La lezione di prova è loccasione perfetta per conoscere il metodo, vivere lesperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova.</p>
<a class="btn btn--dark" href="/contact">Prenota</a>
</div>
</div>
</section>
</Base>
<style>
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
.tblock--reverse img { order: 2; }
.tblock h2 { letter-spacing: .1em; }
.trial { max-width: 720px; text-align: center; }
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
</style>
```
- [ ] **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), `<PageHero />`.
- 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;
---
<Base title="Programmi" description="I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.">
<PageHero title="programmi" />
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Programmi</h2>
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.</p>
</div>
<div class="plist">
{programs.map((p) => (
<article class="plist__card">
<h3>{p.title}</h3>
<p class="plist__sub">{p.subtitle}</p>
<p>{p.excerpt}</p>
<a class="plist__more" href={`/programs/${p.slug}`}>Per saperne di più →</a>
</article>
))}
</div>
</div>
</section>
</Base>
<style>
.plist { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
.plist__card h3 { letter-spacing: .08em; }
.plist__sub { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
.plist__card p { font-size: .92rem; }
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.plist__more:hover { color: var(--c-accent-dark); }
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
</style>
```
- [ ] **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'];
---
<Base title={program.title} description={program.excerpt}>
<PageHero title={program.title.toUpperCase()} />
<section class="section">
<div class="container prog">
<div>
<h2>{program.title.toUpperCase()}</h2>
<p>{program.longDescription}</p>
<ul class="prog__features">
{program.features.map((f) => <li>{f}</li>)}
</ul>
<div class="prog__faq">
{program.faq.map((item, i) => (
<details open={i === 0}>
<summary>{item.q}</summary>
<p>{item.a}</p>
</details>
))}
</div>
</div>
<aside class="prog__side">
<h3>Categorie</h3>
{blogCategories.map((c) => <p><a href="/blog">{c}</a></p>)}
<h3>Altri programmi</h3>
{others.map((o) => <p><a href={`/programs/${o.slug}`}>{o.title}</a></p>)}
</aside>
</div>
</section>
{program.hasPricing && (
<section class="section section--alt">
<div class="container">
<div class="section-heading">
<h2>{performanceClassPricing.heading}</h2>
<p>{performanceClassPricing.intro}</p>
</div>
{performanceClassPricing.groups.map((g) => (
<details class="price__group" open={g.open}>
<summary>{g.open ? '+' : ''} {g.label}</summary>
{g.plans.length === 0 ? (
<p class="price__empty">Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a></p>
) : (
<div class="price__row">
{g.plans.map((p) => (
<div class="price__card">
<p class="price__amount">{p.monthly}€</p>
<p class="price__per">al mese, semestrale</p>
<p class="price__entries">{p.entries} {p.entries === 1 ? 'ingresso settimanale' : 'ingressi settimanali'}</p>
<p>{p.note}</p>
<p>{p.installments}</p>
<p class="price__best">{p.best}</p>
<a class="btn" href="/contact">Acquista</a>
</div>
))}
</div>
)}
</details>
))}
</div>
</section>
)}
</Base>
<style>
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
.prog__features li::marker { color: var(--c-accent); }
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
.prog__faq summary { cursor: pointer; font-family: var(--font-heading); font-size: .85rem; letter-spacing: .08em; color: var(--c-heading); }
.prog__side h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
.prog__side a { text-decoration: none; color: var(--c-text); }
.prog__side a:hover { color: var(--c-accent-dark); }
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
.price__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-top: 30px; }
.price__card { background: #fff; padding: 36px 28px; text-align: center; }
.price__amount { font-family: var(--font-heading); font-size: 2.6rem; color: var(--c-heading); margin: 0; }
.price__per { color: var(--c-text-light); font-size: .8rem; }
.price__entries { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .1em; font-size: .82rem; color: var(--c-heading); }
.price__card p { margin-bottom: 6px; font-size: .85rem; }
.price__best { font-weight: 600; color: var(--c-accent-dark); }
.price__card .btn { margin-top: 16px; }
@media (max-width: 991px) { .prog { grid-template-columns: 1fr; } .price__row { grid-template-columns: 1fr; } }
</style>
```
- [ ] **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), `<ContactForm variant="full" />` (Task 7), `<PageHero />` (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<void>` (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<string, unknown>;
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<void> {
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}`);
---
<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.">
<PageHero title="Contattaci" />
<section class="section">
<div class="container cgrid">
<div class="cmap" id="map-box">
<button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button>
<p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p>
</div>
<ContactForm variant="full" />
</div>
<div class="container cinfo">
<div>
<h3>Indirizzo</h3>
<p>{site.address} {site.cityLine}</p>
</div>
<div>
<h3>Telefono</h3>
<p>{site.phone}</p>
</div>
<div>
<h3>E-mail</h3>
<p><a href={`mailto:${site.email}`}>{site.email}</a></p>
</div>
</div>
<div class="container">
<p class="cinvite">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.</p>
</div>
</section>
</Base>
<style>
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
.cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
.cmap__note { font-size: .8rem; color: var(--c-text-light); }
.cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
.cinvite { max-width: 640px; }
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
</style>
<script define:vars={{ mapsQuery }}>
document.getElementById('map-load')?.addEventListener('click', () => {
const box = document.getElementById('map-box');
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
});
</script>
```
- [ ] **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), `<PageHero />`.
- 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}` : ''}`;
};
---
<Base title="Blog & Edugo" description="Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute.">
<PageHero title="Blog & Edugo" />
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Blog & Edugo</h2>
<p>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.</p>
</div>
<nav class="bfilters" aria-label="categorie">
<a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>Mostra tutto</a>
{CATEGORIES.map((c) => (
<a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>{c}</a>
))}
</nav>
{items.length === 0 ? (
<p style="text-align:center">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
) : (
<div class="bgrid">
{items.map((post) => (
<article class="bcard">
{post.cover && <a href={`/blog/${post.slug}`}><img src={post.cover} alt="" loading="lazy" /></a>}
<p class="bcard__date">{fmt(post.published_at)}</p>
<h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
<p>{post.excerpt}</p>
<a class="bcard__more" href={`/blog/${post.slug}`}>Per saperne di più →</a>
</article>
))}
</div>
)}
{pages > 1 && (
<nav class="bpages" aria-label="paginazione">
{Array.from({ length: pages }, (_, i) => (
<a class:list={['bpages__n', { 'is-active': page === i + 1 }]} href={linkFor(category, i + 1)}>{i + 1}</a>
))}
</nav>
)}
</div>
</section>
</Base>
<style>
.bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
.bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
.bfilters__item.is-active { background: var(--c-accent); color: #fff; }
.bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
.bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
.bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
.bcard h3 a { text-decoration: none; }
.bcard__more { font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.bpages { display: flex; gap: 8px; justify-content: center; margin-top: 50px; }
.bpages__n { font-family: var(--font-heading); text-decoration: none; padding: 8px 14px; color: var(--c-text); }
.bpages__n.is-active { background: var(--c-dark); color: #fff; }
@media (max-width: 991px) { .bgrid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .bgrid { grid-template-columns: 1fr; } }
</style>
```
- [ ] **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`));
---
<aside class="bside">
<h3>Categorie</h3>
{CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
<h3>Ultimi articoli</h3>
{recent.map((p) => (
<p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
))}
<h3>Archivio</h3>
{months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
</aside>
<style>
.bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
.bside a { text-decoration: none; color: var(--c-text); }
.bside a:hover { color: var(--c-accent-dark); }
.bside__month { text-transform: capitalize; }
</style>
```
- [ ] **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' });
---
<Base title={post.title} description={post.excerpt}>
<section class="section">
<div class="container art">
<article>
{post.cover && <img class="art__cover" src={post.cover} alt="" />}
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p>
<h1>{post.title}</h1>
<div class="art__body" set:html={post.body_html} />
</article>
<Sidebar />
</div>
</section>
</Base>
<style>
.art { display: grid; grid-template-columns: 2.4fr 1fr; gap: 60px; }
.art__cover { width: 100%; margin-bottom: 24px; }
.art__meta { font-size: .8rem; color: var(--c-text-light); }
.art__body :global(img) { margin-block: 20px; }
.art__body :global(blockquote) { border-left: 3px solid var(--c-accent); margin: 24px 0; padding: 6px 0 6px 20px; font-style: italic; color: var(--c-heading); }
@media (max-width: 991px) { .art { grid-template-columns: 1fr; } }
</style>
```
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.', '<p>Corpo <strong>di prova</strong>.</p>', 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/<nome>' }`.
- `GET /uploads/<file>` 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 = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
expect(sanitizeHtml(html)).toBe(html);
});
it('rimuove script e handler', () => {
expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
});
it('consente immagini locali e https, blocca javascript:', () => {
expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
});
});
```
- [ ] **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;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Admin InsanityLab</title>
<meta name="robots" content="noindex" />
<style is:global>
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
.abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
.abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
.awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
.abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
.abtn--danger { background: #a33; }
.afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
table { width: 100%; border-collapse: collapse; background: #fff; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
.pill--draft { background: #f3d9a4; }
</style>
</head>
<body>
<div class="abar">
<strong>InsanityLab · Admin</strong>
<span>
{user && <>Ciao, {user.username} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
</span>
</div>
<div class="awrap"><slot /></div>
</body>
</html>
```
`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.';
}
}
---
<Admin title="Login">
<h1>Accesso</h1>
{error && <p style="color:#a33">{error}</p>}
<form method="post" style="max-width:360px">
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button class="abtn" type="submit">Entra</button>
</form>
</Admin>
```
`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' });
---
<Admin title="Articoli">
<h1>Articoli</h1>
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
<table>
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<tbody>
{posts.map((p) => (
<tr>
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
<td>{p.category}</td>
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
</tr>
))}
</tbody>
</table>
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
</Admin>
<script>
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo articolo?')) return;
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
if (res.ok) location.reload();
else alert('Eliminazione non riuscita.');
}));
</script>
```
- [ ] **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;
---
<form id="post-form" data-id={post?.id ?? ''}>
<input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
<input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
<select class="afield" name="category">
{CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
</select>
<textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
<label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
<input type="hidden" name="cover" value={post?.cover ?? ''} />
<p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
<div class="ed-toolbar" id="ed-toolbar">
<button type="button" data-cmd="h2">H2</button>
<button type="button" data-cmd="h3">H3</button>
<button type="button" data-cmd="bold"><b>B</b></button>
<button type="button" data-cmd="italic"><i>I</i></button>
<button type="button" data-cmd="bullet">• Elenco</button>
<button type="button" data-cmd="ordered">1. Elenco</button>
<button type="button" data-cmd="quote">“ Citazione</button>
<button type="button" data-cmd="link">Link</button>
<button type="button" data-cmd="image">Immagine</button>
</div>
<div id="editor" class="ed-body"></div>
<label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
<p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
</form>
<style>
.ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
.ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
.ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
.ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
.ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
</style>
<script>
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
const form = document.getElementById('post-form') as HTMLFormElement;
const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Image,
Link.configure({ openOnClick: false }),
],
content: initial,
});
async function uploadFile(file: File): Promise<string | null> {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
if (!res.ok) { alert('Upload non riuscito.'); return null; }
return (await res.json()).url;
}
document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const c = editor.chain().focus();
switch (btn.dataset.cmd) {
case 'h2': c.toggleHeading({ level: 2 }).run(); break;
case 'h3': c.toggleHeading({ level: 3 }).run(); break;
case 'bold': c.toggleBold().run(); break;
case 'italic': c.toggleItalic().run(); break;
case 'bullet': c.toggleBulletList().run(); break;
case 'ordered': c.toggleOrderedList().run(); break;
case 'quote': c.toggleBlockquote().run(); break;
case 'link': {
const url = prompt('URL del link:');
if (url) c.setLink({ href: url }).run();
break;
}
case 'image': {
const input = document.createElement('input');
input.type = 'file'; input.accept = 'image/*';
input.onchange = async () => {
const url = input.files?.[0] && await uploadFile(input.files[0]);
if (url) editor.chain().focus().setImage({ src: url }).run();
};
input.click();
break;
}
}
});
document.getElementById('cover-file')!.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const url = await uploadFile(file);
if (url) {
(form.elements.namedItem('cover') as HTMLInputElement).value = url;
document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const id = form.dataset.id;
const body = {
title: (form.elements.namedItem('title') as HTMLInputElement).value,
slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
category: (form.elements.namedItem('category') as HTMLSelectElement).value,
excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
body_html: editor.getHTML(),
};
const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const msg = document.getElementById('save-msg')!;
if (res.ok) {
msg.textContent = 'Salvato ✓';
if (!id) location.href = '/admin';
} else {
msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
}
});
</script>
```
`src/pages/admin/new.astro`:
```astro
---
import Admin from '../../layouts/Admin.astro';
import PostForm from '../../components/admin/PostForm.astro';
export const prerender = false;
---
<Admin title="Nuovo articolo">
<h1>Nuovo articolo</h1>
<script id="initial-body" type="text/plain"></script>
<PostForm />
</Admin>
```
`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');
---
<Admin title={`Modifica: ${post.title}`}>
<h1>Modifica articolo</h1>
<script id="initial-body" type="text/plain" set:html={post.body_html}></script>
<PostForm post={post} />
</Admin>
```
- [ ] **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<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
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<string, string> = { '.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<string, string> = { '.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/<slug>`; 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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#3a2d26"/>
<path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b5a48b" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</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 <url> && 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 915 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.