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

152 KiB
Raw Blame History

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:

{
  "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:

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:

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:

{
  "extends": "astro/tsconfigs/strict",
  "include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
  "exclude": ["dist"]
}

vitest.config.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:

/// <reference types="astro/client" />

declare namespace App {
  interface Locals {
    user?: { id: number; username: string };
  }
}

src/pages/index.astro (placeholder, sostituito nel Task 9):

---
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
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:

#!/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:

{
  "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:

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:

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
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

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
/* === 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

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:

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:

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:

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:

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:

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
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:

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:

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:

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
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:

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:

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:

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:

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
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"

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

---
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 /.

---
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
---
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.

---
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
---
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
---
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
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

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).

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)
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)
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
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:

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):

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:

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.

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).

---
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.

---
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):

---
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):

---
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):

---
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:

---
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:

---
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:

---
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:

---
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:

---
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
---
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.

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
---
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>:

---
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.

---
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.

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
---
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.

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
---
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.

---
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.

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:

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:

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:

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:

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.

---
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):

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
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

export const CATEGORIES = [
  'Articoli tecnici & scientifici',
  'Contenuti educativi',
  'LinkedIn / Insight',
  'Eventi & Presidi',
] as const;
  • Step 2: blog/index.astro (SSR)
---
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
---
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)
---
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
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
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:

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:

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:

---
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):

---
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:

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:

---
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
---
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:

---
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:

---
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):

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:

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:

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:

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:

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/admin302 (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
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 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:

#!/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

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.