9780e51137
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3514 lines
152 KiB
Markdown
3514 lines
152 KiB
Markdown
# 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, Lun–Ven 6.00–21.00, Sab 8.00–19.00, Dom chiuso.
|
||
- Commit frequenti, messaggi brevi in italiano, prefissi `feat:`/`fix:`/`chore:`/`test:`.
|
||
|
||
---
|
||
|
||
### Task 1: Scaffold progetto Astro
|
||
|
||
**Files:**
|
||
- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `vitest.config.ts`, `.gitignore`, `.env.example`, `README.md`, `src/pages/index.astro` (placeholder), `src/env.d.ts`, `data/.gitkeep`, `uploads/.gitkeep`
|
||
|
||
**Interfaces:**
|
||
- Produces: progetto buildabile; script npm `dev`, `build`, `preview`, `start`, `test`, `create-user`.
|
||
|
||
- [ ] **Step 1: package.json e installazione dipendenze**
|
||
|
||
Crea `package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "insanitylab-website",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "astro dev",
|
||
"build": "astro build",
|
||
"preview": "astro preview",
|
||
"start": "node --env-file=.env ./dist/server/entry.mjs",
|
||
"test": "vitest run",
|
||
"create-user": "node scripts/create-user.mjs"
|
||
}
|
||
}
|
||
```
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website
|
||
npm install astro @astrojs/node @astrojs/sitemap better-sqlite3 bcryptjs nodemailer sanitize-html @fontsource/montserrat @fontsource/open-sans @tiptap/core @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link
|
||
npm install -D vitest typescript @types/better-sqlite3 @types/bcryptjs @types/nodemailer @types/sanitize-html
|
||
```
|
||
Expected: install senza errori (better-sqlite3 compila nativo).
|
||
|
||
- [ ] **Step 2: file di configurazione**
|
||
|
||
`astro.config.mjs`:
|
||
```js
|
||
import { defineConfig } from 'astro/config';
|
||
import node from '@astrojs/node';
|
||
import sitemap from '@astrojs/sitemap';
|
||
|
||
export default defineConfig({
|
||
site: 'https://insanitylab.tielogic.xyz',
|
||
output: 'server',
|
||
adapter: node({ mode: 'standalone' }),
|
||
integrations: [sitemap()],
|
||
});
|
||
```
|
||
|
||
`tsconfig.json`:
|
||
```json
|
||
{
|
||
"extends": "astro/tsconfigs/strict",
|
||
"include": [".astro/types.d.ts", "src/**/*", "tests/**/*"],
|
||
"exclude": ["dist"]
|
||
}
|
||
```
|
||
|
||
`vitest.config.ts`:
|
||
```ts
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
test: { include: ['tests/**/*.test.ts'] },
|
||
});
|
||
```
|
||
|
||
`.gitignore`:
|
||
```
|
||
node_modules/
|
||
dist/
|
||
.astro/
|
||
.env
|
||
data/*.db*
|
||
uploads/*
|
||
!uploads/.gitkeep
|
||
assets-src/
|
||
```
|
||
|
||
`.env.example`:
|
||
```
|
||
SMTP_HOST=smtp.example.com
|
||
SMTP_PORT=587
|
||
SMTP_USER=user
|
||
SMTP_PASS=pass
|
||
CONTACT_TO=info@insanitylab.it
|
||
CONTACT_FROM=sito@insanitylab.it
|
||
DB_PATH=data/insanitylab.db
|
||
UPLOADS_DIR=uploads
|
||
```
|
||
|
||
`src/env.d.ts`:
|
||
```ts
|
||
/// <reference types="astro/client" />
|
||
|
||
declare namespace App {
|
||
interface Locals {
|
||
user?: { id: number; username: string };
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/pages/index.astro` (placeholder, sostituito nel Task 9):
|
||
```astro
|
||
---
|
||
export const prerender = true;
|
||
---
|
||
<html lang="it"><head><title>InsanityLab</title></head>
|
||
<body><h1>InsanityLab — in costruzione</h1></body></html>
|
||
```
|
||
|
||
Crea `data/.gitkeep` e `uploads/.gitkeep` vuoti. Copia `.env.example` in `.env`.
|
||
|
||
- [ ] **Step 3: README.md**
|
||
|
||
Prosa italiana completa: scopo (sito vetrina InsanityLab con blog e form contatti), setup (`npm install`, `cp .env.example .env`, `npm run create-user -- <user> <pass>`), comandi (`dev`, `build`, `start`, `test`), stack, struttura cartelle, riferimento alla spec in `docs/superpowers/specs/`.
|
||
|
||
- [ ] **Step 4: verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build completata, cartella `dist/` creata.
|
||
|
||
- [ ] **Step 5: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "chore: scaffold progetto Astro 5 con adapter node"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Estrazione asset da Figma
|
||
|
||
**Files:**
|
||
- Create: `scripts/extract-fig-images.sh`, `scripts/asset-map.json`, `scripts/apply-asset-map.mjs`, `src/assets/img/*` (immagini rinominate), `public/img/logo-*.png`
|
||
|
||
**Interfaces:**
|
||
- Produces: immagini in `src/assets/img/` con nomi semantici (`hero-1.png`, `info-performance.jpg`, `team-donata.jpg`, `program-one-to-one.jpg`, `partner-edenred.png`, ecc.); loghi in `public/img/`.
|
||
|
||
- [ ] **Step 1: script estrazione**
|
||
|
||
`scripts/extract-fig-images.sh`:
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
cd "$(dirname "$0")/.."
|
||
FIG="../FigmaDoc/Web Insanity.fig"
|
||
OUT="assets-src"
|
||
rm -rf "$OUT" && mkdir -p "$OUT"
|
||
unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
|
||
cd "$OUT"
|
||
for f in *; do
|
||
case "$(file --brief --mime-type "$f")" in
|
||
image/png) mv "$f" "$f.png" ;;
|
||
image/jpeg) mv "$f" "$f.jpg" ;;
|
||
image/webp) mv "$f" "$f.webp" ;;
|
||
*) rm -f "$f" ;;
|
||
esac
|
||
done
|
||
echo "Estratte: $(ls | wc -l) immagini in $OUT/"
|
||
```
|
||
|
||
Run: `chmod +x scripts/extract-fig-images.sh && ./scripts/extract-fig-images.sh`
|
||
Expected: decine di immagini con estensione in `assets-src/`.
|
||
|
||
- [ ] **Step 2: identificazione visiva e mappatura**
|
||
|
||
Visualizza le immagini estratte (tool Read su ogni file, oppure contact sheet con `montage assets-src/*.{png,jpg} -tile 6x -geometry 200x200+4+4 -label '%f' assets-src/sheet_%d.png`). Compila `scripts/asset-map.json` associando ogni hash al nome di destinazione. Formato:
|
||
|
||
```json
|
||
{
|
||
"src/assets/img": {
|
||
"<hash>.jpg": "hero-1.jpg",
|
||
"<hash>.jpg": "info-performance.jpg",
|
||
"<hash>.jpg": "info-balance.jpg",
|
||
"<hash>.jpg": "info-longevity.jpg",
|
||
"<hash>.jpg": "feature-performance.jpg",
|
||
"<hash>.jpg": "feature-balance.jpg",
|
||
"<hash>.jpg": "feature-longevity.jpg",
|
||
"<hash>.jpg": "quote-bg.jpg",
|
||
"<hash>.jpg": "training-one-to-one.jpg",
|
||
"<hash>.jpg": "training-small-groups.jpg",
|
||
"<hash>.jpg": "training-performance-class.jpg",
|
||
"<hash>.jpg": "training-fit-remote.jpg",
|
||
"<hash>.jpg": "team-donata.jpg",
|
||
"<hash>.jpg": "team-nicola.jpg",
|
||
"<hash>.jpg": "team-eleonora.jpg",
|
||
"<hash>.jpg": "team-antonello.jpg",
|
||
"<hash>.jpg": "method-step-1.jpg",
|
||
"<hash>.jpg": "method-step-2.jpg",
|
||
"<hash>.jpg": "method-step-3.jpg",
|
||
"<hash>.jpg": "method-step-4.jpg",
|
||
"<hash>.png": "partner-edenred.png",
|
||
"<hash>.png": "partner-welbee.png",
|
||
"<hash>.png": "partner-aon.png",
|
||
"<hash>.png": "partner-howden.png"
|
||
},
|
||
"public/img": {
|
||
"<hash>.png": "logo-dark.png",
|
||
"<hash>.png": "logo-light.png"
|
||
}
|
||
}
|
||
```
|
||
|
||
Nomi aggiuntivi liberi per immagini di about/training trovate nell'archivio (`about-*.jpg`, ecc.). Le immagini non usate restano in `assets-src/` (gitignored). Se un'immagine attesa manca (es. loghi), fallback: `../Dati/logo-dark.png`, `../Dati/logo-light.png` e altri file in `../Dati/`.
|
||
|
||
- [ ] **Step 3: script applicazione mappa**
|
||
|
||
`scripts/apply-asset-map.mjs`:
|
||
```js
|
||
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
|
||
let n = 0, missing = 0;
|
||
for (const [destDir, entries] of Object.entries(map)) {
|
||
mkdirSync(join(root, destDir), { recursive: true });
|
||
for (const [src, dest] of Object.entries(entries)) {
|
||
const from = join(root, 'assets-src', src);
|
||
if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
|
||
copyFileSync(from, join(root, destDir, dest));
|
||
n++;
|
||
}
|
||
}
|
||
console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
|
||
process.exit(missing ? 1 : 0);
|
||
```
|
||
|
||
Run: `node scripts/apply-asset-map.mjs`
|
||
Expected: `Copiate N immagini`, exit 0.
|
||
|
||
- [ ] **Step 4: ottimizza pesi**
|
||
|
||
Le immagini Figma possono superare 5 MB. Ridimensiona quelle sopra 2000 px di larghezza:
|
||
```bash
|
||
cd src/assets/img
|
||
for f in *.jpg *.png; do
|
||
w=$(identify -format '%w' "$f")
|
||
[ "$w" -gt 2000 ] && mogrify -resize 2000x "$f" && echo "ridotta: $f"
|
||
done
|
||
```
|
||
Expected: nessuna immagine > 2000 px; `du -sh src/assets/img` sotto ~15 MB.
|
||
|
||
- [ ] **Step 5: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: asset immagini estratti da Figma e mappati"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Design tokens e CSS globale
|
||
|
||
**Files:**
|
||
- Create: `src/styles/global.css`
|
||
|
||
**Interfaces:**
|
||
- Produces: variabili CSS `--c-accent`, `--c-accent-dark`, `--c-dark`, `--c-text`, `--c-heading`, `--c-bg-alt`; classi utility `.container`, `.btn`, `.btn--dark`, `.btn--light`, `.section`, `.section--dark`, `.section-heading`, `.eyebrow`; font family var `--font-heading`, `--font-body`.
|
||
|
||
- [ ] **Step 1: campiona colori esatti dai PDF**
|
||
|
||
```bash
|
||
S=/tmp/ilab-sample && mkdir -p $S
|
||
pdftoppm -png -r 30 "../FigmaDoc/Homepage.pdf" $S/h
|
||
# bottone SCOPRI hero (~x55,y200 alla risoluzione 30dpi), sezione agenda scura, sfondo chiaro
|
||
convert $S/h-1.png -format "%[pixel:p{60,205}] %[pixel:p{50,1500}] %[pixel:p{300,1360}]" info:
|
||
```
|
||
Annota i valori RGB reali e usali negli step successivi al posto dei default indicati (attesi: tan ~`#b5a48b`, marrone ~`#3a2d26`, chiaro ~`#f5f3f0`).
|
||
|
||
- [ ] **Step 2: global.css**
|
||
|
||
```css
|
||
/* === Design tokens (valori campionati da Figma) === */
|
||
:root {
|
||
--c-accent: #b5a48b;
|
||
--c-accent-dark: #9c8b70;
|
||
--c-dark: #3a2d26;
|
||
--c-dark-2: #4a3a30;
|
||
--c-heading: #2b2b2b;
|
||
--c-text: #4b4b4b;
|
||
--c-text-light: #8a8a8a;
|
||
--c-bg: #ffffff;
|
||
--c-bg-alt: #f5f3f0;
|
||
--font-heading: 'Montserrat', sans-serif;
|
||
--font-body: 'Open Sans', sans-serif;
|
||
--header-h: 70px;
|
||
}
|
||
|
||
/* === Reset === */
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
body { margin: 0; font-family: var(--font-body); color: var(--c-text); font-size: 16px; line-height: 1.7; background: var(--c-bg); }
|
||
img { max-width: 100%; height: auto; display: block; }
|
||
h1, h2, h3, h4 { font-family: var(--font-heading); color: var(--c-heading); font-weight: 500; letter-spacing: .04em; line-height: 1.2; margin: 0 0 .5em; }
|
||
h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); }
|
||
h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
|
||
h3 { font-size: 1.3rem; }
|
||
a { color: inherit; }
|
||
p { margin: 0 0 1em; }
|
||
|
||
/* === Utility === */
|
||
.container { max-width: 1200px; margin-inline: auto; padding-inline: 20px; }
|
||
.section { padding-block: 90px; }
|
||
.section--alt { background: var(--c-bg-alt); }
|
||
.section--dark { background: var(--c-dark); color: #cfc6bd; }
|
||
.section--dark h2, .section--dark h3 { color: #fff; }
|
||
.eyebrow { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .25em; font-size: .75rem; color: var(--c-accent-dark); }
|
||
.section-heading { text-align: center; max-width: 640px; margin: 0 auto 56px; }
|
||
.section-heading h2 { text-transform: uppercase; letter-spacing: .12em; }
|
||
|
||
.btn { display: inline-flex; align-items: center; gap: 14px; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; cursor: pointer; padding: 16px 26px; background: var(--c-accent); color: #fff; transition: background .2s; }
|
||
.btn:hover { background: var(--c-accent-dark); }
|
||
.btn::after { content: '→'; font-size: .9rem; }
|
||
.btn--dark { background: var(--c-dark); }
|
||
.btn--dark:hover { background: var(--c-dark-2); }
|
||
.btn--light { background: #fff; color: var(--c-heading); }
|
||
|
||
/* === Form === */
|
||
.field { width: 100%; border: 1px solid #ddd; background: #fff; padding: 14px 16px; font-family: var(--font-body); font-size: .95rem; color: var(--c-text); margin-bottom: 14px; }
|
||
.field:focus { outline: 2px solid var(--c-accent); outline-offset: -1px; }
|
||
textarea.field { min-height: 120px; resize: vertical; }
|
||
.form-msg { font-size: .9rem; margin-top: 8px; }
|
||
.form-msg--ok { color: #3c7a3c; }
|
||
.form-msg--err { color: #b03030; }
|
||
|
||
/* honeypot */
|
||
.hp { position: absolute; left: -9999px; opacity: 0; }
|
||
```
|
||
|
||
- [ ] **Step 2b: aggiorna i valori con quelli campionati allo Step 1.**
|
||
|
||
- [ ] **Step 3: commit**
|
||
|
||
```bash
|
||
git add src/styles/global.css && git commit -m "feat: design tokens e css globale"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Lib di base — slugify e rate limit (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/slug.ts`, `src/lib/rate-limit.ts`, `src/lib/env.ts`
|
||
- Test: `tests/slug.test.ts`, `tests/rate-limit.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `slugify(input: string): string`; `rateLimit(key: string, max: number, windowMs: number): boolean` (true = consentito); `getEnv(name: string, fallback?: string): string`.
|
||
|
||
- [ ] **Step 1: test slugify (falliranno)**
|
||
|
||
`tests/slug.test.ts`:
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { slugify } from '../src/lib/slug';
|
||
|
||
describe('slugify', () => {
|
||
it('minuscole e trattini', () => {
|
||
expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab');
|
||
});
|
||
it('rimuove accenti', () => {
|
||
expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui');
|
||
});
|
||
it('comprime simboli e spazi multipli', () => {
|
||
expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news');
|
||
});
|
||
it('niente trattini ai bordi', () => {
|
||
expect(slugify(' ciao ')).toBe('ciao');
|
||
});
|
||
it('stringa vuota resta vuota', () => {
|
||
expect(slugify('')).toBe('');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: verifica che falliscano**
|
||
|
||
Run: `npx vitest run tests/slug.test.ts`
|
||
Expected: FAIL (`slugify` non esiste).
|
||
|
||
- [ ] **Step 3: implementa slugify**
|
||
|
||
`src/lib/slug.ts`:
|
||
```ts
|
||
export function slugify(input: string): string {
|
||
return input
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: test rate limit (falliranno)**
|
||
|
||
`tests/rate-limit.test.ts`:
|
||
```ts
|
||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||
import { rateLimit, _resetBuckets } from '../src/lib/rate-limit';
|
||
|
||
afterEach(() => { _resetBuckets(); vi.useRealTimers(); });
|
||
|
||
describe('rateLimit', () => {
|
||
it('consente fino a max richieste', () => {
|
||
expect(rateLimit('a', 2, 1000)).toBe(true);
|
||
expect(rateLimit('a', 2, 1000)).toBe(true);
|
||
expect(rateLimit('a', 2, 1000)).toBe(false);
|
||
});
|
||
it('chiavi indipendenti', () => {
|
||
expect(rateLimit('a', 1, 1000)).toBe(true);
|
||
expect(rateLimit('b', 1, 1000)).toBe(true);
|
||
});
|
||
it('la finestra scade', () => {
|
||
vi.useFakeTimers();
|
||
expect(rateLimit('a', 1, 1000)).toBe(true);
|
||
expect(rateLimit('a', 1, 1000)).toBe(false);
|
||
vi.advanceTimersByTime(1100);
|
||
expect(rateLimit('a', 1, 1000)).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: implementa rate-limit ed env**
|
||
|
||
`src/lib/rate-limit.ts`:
|
||
```ts
|
||
const buckets = new Map<string, number[]>();
|
||
|
||
export function rateLimit(key: string, max: number, windowMs: number): boolean {
|
||
const now = Date.now();
|
||
const recent = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
|
||
if (recent.length >= max) {
|
||
buckets.set(key, recent);
|
||
return false;
|
||
}
|
||
recent.push(now);
|
||
buckets.set(key, recent);
|
||
return true;
|
||
}
|
||
|
||
export function _resetBuckets(): void {
|
||
buckets.clear();
|
||
}
|
||
```
|
||
|
||
`src/lib/env.ts`:
|
||
```ts
|
||
export function getEnv(name: string, fallback?: string): string {
|
||
const v = process.env[name] ?? (import.meta.env ? import.meta.env[name] : undefined) ?? fallback;
|
||
if (v === undefined) throw new Error(`Variabile d'ambiente mancante: ${name}`);
|
||
return v;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: verifica test verdi**
|
||
|
||
Run: `npm test`
|
||
Expected: tutti PASS.
|
||
|
||
- [ ] **Step 7: commit**
|
||
|
||
```bash
|
||
git add src/lib tests && git commit -m "feat: slugify, rate limit ed env helper con test"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Layer database — schema e repository posts (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/db.ts`, `src/lib/posts.ts`
|
||
- Test: `tests/posts.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `slugify` da Task 4 (nei consumer, non qui).
|
||
- Produces:
|
||
- `createDb(path?: string): Database.Database` — apre/crea DB, applica schema idempotente.
|
||
- `getDb(): Database.Database` — singleton su `DB_PATH`.
|
||
- Tipo `Post { id: number; title: string; slug: string; category: string; excerpt: string; body_html: string; cover: string | null; draft: number; published_at: string | null; created_at: string; updated_at: string }`.
|
||
- `PostInput { title: string; slug: string; category: string; excerpt: string; body_html: string; cover?: string | null; draft: boolean }`.
|
||
- `createPost(db, input: PostInput): number` (ritorna id; se `draft=false` imposta `published_at`).
|
||
- `updatePost(db, id: number, input: PostInput): void` (imposta `published_at` alla prima pubblicazione, aggiorna `updated_at`).
|
||
- `deletePost(db, id: number): void`
|
||
- `getPostById(db, id: number): Post | undefined`
|
||
- `getPublishedBySlug(db, slug: string): Post | undefined`
|
||
- `listPublished(db, opts?: { category?: string; page?: number; perPage?: number }): { items: Post[]; total: number }` (ordinati per `published_at` desc)
|
||
- `listAllPosts(db): Post[]` (anche bozze, per admin)
|
||
- `listRecent(db, limit?: number): Post[]`
|
||
- `listArchiveMonths(db): { month: string; count: number }[]` (formato `YYYY-MM`)
|
||
|
||
- [ ] **Step 1: implementa db.ts**
|
||
|
||
`src/lib/db.ts`:
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { mkdirSync } from 'node:fs';
|
||
import { dirname } from 'node:path';
|
||
|
||
const SCHEMA = `
|
||
CREATE TABLE IF NOT EXISTS posts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
title TEXT NOT NULL,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
category TEXT NOT NULL,
|
||
excerpt TEXT NOT NULL DEFAULT '',
|
||
body_html TEXT NOT NULL DEFAULT '',
|
||
cover TEXT,
|
||
draft INTEGER NOT NULL DEFAULT 1,
|
||
published_at TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token TEXT PRIMARY KEY,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
expires_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
|
||
`;
|
||
|
||
export function createDb(path?: string): Database.Database {
|
||
const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
|
||
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
|
||
const db = new Database(p);
|
||
db.pragma('journal_mode = WAL');
|
||
db.pragma('foreign_keys = ON');
|
||
db.exec(SCHEMA);
|
||
return db;
|
||
}
|
||
|
||
let singleton: Database.Database | null = null;
|
||
|
||
export function getDb(): Database.Database {
|
||
if (!singleton) singleton = createDb();
|
||
return singleton;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: test posts (falliranno)**
|
||
|
||
`tests/posts.test.ts`:
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import type Database from 'better-sqlite3';
|
||
import { createDb } from '../src/lib/db';
|
||
import {
|
||
createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
|
||
listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
|
||
} from '../src/lib/posts';
|
||
|
||
let db: Database.Database;
|
||
beforeEach(() => { db = createDb(':memory:'); });
|
||
|
||
const base: PostInput = {
|
||
title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
|
||
excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
|
||
};
|
||
|
||
describe('posts repository', () => {
|
||
it('crea e rilegge un post pubblicato', () => {
|
||
const id = createPost(db, base);
|
||
const post = getPostById(db, id)!;
|
||
expect(post.title).toBe('Titolo');
|
||
expect(post.draft).toBe(0);
|
||
expect(post.published_at).not.toBeNull();
|
||
});
|
||
|
||
it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
|
||
const id = createPost(db, { ...base, slug: 'bozza', draft: true });
|
||
expect(getPostById(db, id)!.published_at).toBeNull();
|
||
expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
|
||
expect(listPublished(db).total).toBe(0);
|
||
expect(listAllPosts(db)).toHaveLength(1);
|
||
});
|
||
|
||
it('slug duplicato lancia', () => {
|
||
createPost(db, base);
|
||
expect(() => createPost(db, base)).toThrow();
|
||
});
|
||
|
||
it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
|
||
const id = createPost(db, { ...base, draft: true });
|
||
updatePost(db, id, { ...base, draft: false });
|
||
const first = getPostById(db, id)!.published_at;
|
||
expect(first).not.toBeNull();
|
||
updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
|
||
expect(getPostById(db, id)!.published_at).toBe(first);
|
||
expect(getPostById(db, id)!.title).toBe('Nuovo');
|
||
});
|
||
|
||
it('listPublished filtra per categoria e pagina', () => {
|
||
for (let i = 0; i < 12; i++) {
|
||
createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
|
||
}
|
||
const all = listPublished(db, { page: 1, perPage: 9 });
|
||
expect(all.total).toBe(12);
|
||
expect(all.items).toHaveLength(9);
|
||
const edu = listPublished(db, { category: 'Contenuti educativi' });
|
||
expect(edu.total).toBe(6);
|
||
});
|
||
|
||
it('delete rimuove', () => {
|
||
const id = createPost(db, base);
|
||
deletePost(db, id);
|
||
expect(getPostById(db, id)).toBeUndefined();
|
||
});
|
||
|
||
it('listRecent e archivio mesi', () => {
|
||
createPost(db, base);
|
||
expect(listRecent(db, 4)).toHaveLength(1);
|
||
const months = listArchiveMonths(db);
|
||
expect(months).toHaveLength(1);
|
||
expect(months[0].count).toBe(1);
|
||
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: verifica che falliscano**
|
||
|
||
Run: `npx vitest run tests/posts.test.ts`
|
||
Expected: FAIL (modulo `posts` mancante).
|
||
|
||
- [ ] **Step 4: implementa posts.ts**
|
||
|
||
`src/lib/posts.ts`:
|
||
```ts
|
||
import type Database from 'better-sqlite3';
|
||
|
||
export interface Post {
|
||
id: number; title: string; slug: string; category: string; excerpt: string;
|
||
body_html: string; cover: string | null; draft: number;
|
||
published_at: string | null; created_at: string; updated_at: string;
|
||
}
|
||
|
||
export interface PostInput {
|
||
title: string; slug: string; category: string; excerpt: string;
|
||
body_html: string; cover?: string | null; draft: boolean;
|
||
}
|
||
|
||
export function createPost(db: Database.Database, input: PostInput): number {
|
||
const res = db.prepare(
|
||
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
|
||
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
|
||
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
|
||
).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||
return Number(res.lastInsertRowid);
|
||
}
|
||
|
||
export function updatePost(db: Database.Database, id: number, input: PostInput): void {
|
||
db.prepare(
|
||
`UPDATE posts SET title = @title, slug = @slug, category = @category,
|
||
excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft,
|
||
published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END,
|
||
updated_at = datetime('now')
|
||
WHERE id = @id`
|
||
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||
}
|
||
|
||
export function deletePost(db: Database.Database, id: number): void {
|
||
db.prepare('DELETE FROM posts WHERE id = ?').run(id);
|
||
}
|
||
|
||
export function getPostById(db: Database.Database, id: number): Post | undefined {
|
||
return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined;
|
||
}
|
||
|
||
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
|
||
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
|
||
}
|
||
|
||
export function listPublished(
|
||
db: Database.Database,
|
||
opts: { category?: string; page?: number; perPage?: number } = {},
|
||
): { items: Post[]; total: number } {
|
||
const { category, page = 1, perPage = 9 } = opts;
|
||
const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
|
||
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
|
||
.get({ category }) as { n: number }).n;
|
||
const items = db.prepare(
|
||
`SELECT * FROM posts ${where} ORDER BY published_at DESC LIMIT @limit OFFSET @offset`
|
||
).all({ category, limit: perPage, offset: (page - 1) * perPage }) as Post[];
|
||
return { items, total };
|
||
}
|
||
|
||
export function listAllPosts(db: Database.Database): Post[] {
|
||
return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
|
||
}
|
||
|
||
export function listRecent(db: Database.Database, limit = 4): Post[] {
|
||
return db.prepare(
|
||
'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
|
||
).all(limit) as Post[];
|
||
}
|
||
|
||
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
|
||
return db.prepare(
|
||
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
|
||
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
|
||
).all() as { month: string; count: number }[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: verifica test verdi**
|
||
|
||
Run: `npm test`
|
||
Expected: tutti PASS.
|
||
|
||
- [ ] **Step 6: commit**
|
||
|
||
```bash
|
||
git add src/lib/db.ts src/lib/posts.ts tests/posts.test.ts && git commit -m "feat: layer sqlite e repository posts con test"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Autenticazione, middleware e script create-user (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/auth.ts`, `src/middleware.ts`, `scripts/create-user.mjs`
|
||
- Test: `tests/auth.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createDb`/`getDb` (Task 5), `rateLimit` (Task 4).
|
||
- Produces:
|
||
- `hashPassword(plain: string): string`, `verifyPassword(plain: string, hash: string): boolean`
|
||
- `createUser(db, username: string, password: string): number`
|
||
- `login(db, username: string, password: string): string | null` — token sessione o null.
|
||
- `getSessionUser(db, token: string): { id: number; username: string } | null` — null se scaduta (e la elimina).
|
||
- `logout(db, token: string): void`
|
||
- Costante `SESSION_COOKIE = 'session'`, durata 7 giorni.
|
||
- Middleware: protegge `/admin*` (tranne `/admin/login`) e `/api/admin*`; popola `Astro.locals.user`.
|
||
|
||
- [ ] **Step 1: test auth (falliranno)**
|
||
|
||
`tests/auth.test.ts`:
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import type Database from 'better-sqlite3';
|
||
import { createDb } from '../src/lib/db';
|
||
import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
|
||
|
||
let db: Database.Database;
|
||
beforeEach(() => { db = createDb(':memory:'); });
|
||
|
||
describe('auth', () => {
|
||
it('hash e verifica password', () => {
|
||
const h = hashPassword('segreta123');
|
||
expect(h).not.toBe('segreta123');
|
||
expect(verifyPassword('segreta123', h)).toBe(true);
|
||
expect(verifyPassword('sbagliata', h)).toBe(false);
|
||
});
|
||
|
||
it('login corretto crea sessione recuperabile', () => {
|
||
createUser(db, 'adriano', 'segreta123');
|
||
const token = login(db, 'adriano', 'segreta123');
|
||
expect(token).toBeTruthy();
|
||
const user = getSessionUser(db, token!);
|
||
expect(user).toMatchObject({ username: 'adriano' });
|
||
});
|
||
|
||
it('login errato ritorna null', () => {
|
||
createUser(db, 'adriano', 'segreta123');
|
||
expect(login(db, 'adriano', 'sbagliata')).toBeNull();
|
||
expect(login(db, 'inesistente', 'x')).toBeNull();
|
||
});
|
||
|
||
it('sessione scaduta viene rifiutata ed eliminata', () => {
|
||
createUser(db, 'adriano', 'segreta123');
|
||
const token = login(db, 'adriano', 'segreta123')!;
|
||
db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
|
||
expect(getSessionUser(db, token)).toBeNull();
|
||
expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
|
||
});
|
||
|
||
it('logout elimina la sessione', () => {
|
||
createUser(db, 'adriano', 'segreta123');
|
||
const token = login(db, 'adriano', 'segreta123')!;
|
||
logout(db, token);
|
||
expect(getSessionUser(db, token)).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: verifica che falliscano**
|
||
|
||
Run: `npx vitest run tests/auth.test.ts`
|
||
Expected: FAIL (modulo `auth` mancante).
|
||
|
||
- [ ] **Step 3: implementa auth.ts**
|
||
|
||
`src/lib/auth.ts`:
|
||
```ts
|
||
import type Database from 'better-sqlite3';
|
||
import bcrypt from 'bcryptjs';
|
||
import { randomBytes } from 'node:crypto';
|
||
|
||
export const SESSION_COOKIE = 'session';
|
||
const SESSION_DAYS = 7;
|
||
|
||
export function hashPassword(plain: string): string {
|
||
return bcrypt.hashSync(plain, 12);
|
||
}
|
||
|
||
export function verifyPassword(plain: string, hash: string): boolean {
|
||
return bcrypt.compareSync(plain, hash);
|
||
}
|
||
|
||
export function createUser(db: Database.Database, username: string, password: string): number {
|
||
const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||
.run(username, hashPassword(password));
|
||
return Number(res.lastInsertRowid);
|
||
}
|
||
|
||
export function login(db: Database.Database, username: string, password: string): string | null {
|
||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
|
||
| { id: number; password_hash: string } | undefined;
|
||
if (!user || !verifyPassword(password, user.password_hash)) return null;
|
||
const token = randomBytes(32).toString('hex');
|
||
db.prepare(
|
||
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))`
|
||
).run(token, user.id);
|
||
return token;
|
||
}
|
||
|
||
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null {
|
||
const row = db.prepare(
|
||
`SELECT s.token, s.expires_at, u.id, u.username
|
||
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
|
||
).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined;
|
||
if (!row) return null;
|
||
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
|
||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||
return null;
|
||
}
|
||
return { id: row.id, username: row.username };
|
||
}
|
||
|
||
export function logout(db: Database.Database, token: string): void {
|
||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: verifica test verdi**
|
||
|
||
Run: `npm test`
|
||
Expected: tutti PASS.
|
||
|
||
- [ ] **Step 5: middleware**
|
||
|
||
`src/middleware.ts`:
|
||
```ts
|
||
import { defineMiddleware } from 'astro:middleware';
|
||
import { getDb } from './lib/db';
|
||
import { getSessionUser, SESSION_COOKIE } from './lib/auth';
|
||
|
||
export const onRequest = defineMiddleware((context, next) => {
|
||
const { pathname } = context.url;
|
||
const isProtected =
|
||
(pathname.startsWith('/admin') && pathname !== '/admin/login') ||
|
||
pathname.startsWith('/api/admin');
|
||
if (!isProtected) return next();
|
||
|
||
const token = context.cookies.get(SESSION_COOKIE)?.value;
|
||
const user = token ? getSessionUser(getDb(), token) : null;
|
||
if (!user) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return new Response(JSON.stringify({ error: 'Non autorizzato' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
return context.redirect('/admin/login');
|
||
}
|
||
context.locals.user = user;
|
||
return next();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: script create-user**
|
||
|
||
`scripts/create-user.mjs`:
|
||
```js
|
||
import Database from 'better-sqlite3';
|
||
import bcrypt from 'bcryptjs';
|
||
import { mkdirSync } from 'node:fs';
|
||
import { dirname } from 'node:path';
|
||
|
||
const [, , username, password] = process.argv;
|
||
if (!username || !password) {
|
||
console.error('Uso: npm run create-user -- <username> <password>');
|
||
process.exit(1);
|
||
}
|
||
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
const db = new Database(path);
|
||
db.exec(`CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL
|
||
);`);
|
||
db.prepare(
|
||
`INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
|
||
).run(username, bcrypt.hashSync(password, 12));
|
||
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
|
||
```
|
||
|
||
Run: `npm run create-user -- admin prova1234 && sqlite3 data/insanitylab.db 'SELECT username FROM users;'`
|
||
Expected: `admin`.
|
||
|
||
- [ ] **Step 7: commit**
|
||
|
||
```bash
|
||
git add src/lib/auth.ts src/middleware.ts scripts/create-user.mjs tests/auth.test.ts
|
||
git commit -m "feat: autenticazione a sessioni, middleware admin e script create-user"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Layout base, Header, Footer, 404
|
||
|
||
**Files:**
|
||
- Create: `src/layouts/Base.astro`, `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro`, `src/pages/404.astro`
|
||
- Modify: `src/pages/index.astro` (usa Base)
|
||
- Depends: `src/data/site.ts` (Task 8 — eseguire Task 8 prima o creare qui il file e ampliarlo lì; ordine consigliato: Task 8 poi Task 7 se si preferisce, sono indipendenti dagli altri)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `site` da `src/data/site.ts` (Task 8): `{ name, phone, email, address, cityLine, hours: string[], socials: { label, url }[], navigation: { label, href }[] }`.
|
||
- Produces: `<Base title description>` layout con slot; `<ContactForm variant="full" | "footer" />` che POSTa a `/api/contact` via fetch.
|
||
|
||
- [ ] **Step 1: Base.astro**
|
||
|
||
```astro
|
||
---
|
||
import '@fontsource/montserrat/400.css';
|
||
import '@fontsource/montserrat/500.css';
|
||
import '@fontsource/montserrat/600.css';
|
||
import '@fontsource/montserrat/700.css';
|
||
import '@fontsource/open-sans/400.css';
|
||
import '@fontsource/open-sans/600.css';
|
||
import '../styles/global.css';
|
||
import Header from '../components/Header.astro';
|
||
import Footer from '../components/Footer.astro';
|
||
|
||
interface Props { title: string; description?: string }
|
||
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
|
||
---
|
||
<!doctype html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>{title} — InsanityLab</title>
|
||
<meta name="description" content={description} />
|
||
<meta property="og:title" content={`${title} — InsanityLab`} />
|
||
<meta property="og:description" content={description} />
|
||
<meta property="og:type" content="website" />
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||
</head>
|
||
<body>
|
||
<Header />
|
||
<main><slot /></main>
|
||
<Footer />
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 2: Header.astro**
|
||
|
||
Nav da `site.navigation`: Insanitylab→`/about`, Training→`/training`, Programmi→`/programs`, Blog & Edugo→`/blog`, Contatti→`/contact`. Logo `/img/logo-dark.png` a sinistra, link `/`.
|
||
|
||
```astro
|
||
---
|
||
import { site } from '../data/site';
|
||
const path = Astro.url.pathname;
|
||
---
|
||
<header class="hdr" id="site-header">
|
||
<div class="container hdr__in">
|
||
<a href="/" class="hdr__logo"><img src="/img/logo-dark.png" alt="InsanityLab" width="150" /></a>
|
||
<nav class="hdr__nav" id="site-nav" aria-label="principale">
|
||
{site.navigation.map((item) => (
|
||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a>
|
||
))}
|
||
</nav>
|
||
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
|
||
<span></span><span></span><span></span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<style>
|
||
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
|
||
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
|
||
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
|
||
.hdr__nav { display: flex; gap: 34px; }
|
||
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
|
||
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
|
||
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
|
||
@media (max-width: 991px) {
|
||
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
|
||
.hdr__nav.is-open { display: flex; }
|
||
.hdr__link { padding: 12px 0; }
|
||
.hdr__burger { display: block; }
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
const header = document.getElementById('site-header')!;
|
||
const toggle = document.getElementById('nav-toggle')!;
|
||
const nav = document.getElementById('site-nav')!;
|
||
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
|
||
toggle.addEventListener('click', () => {
|
||
const open = nav.classList.toggle('is-open');
|
||
toggle.setAttribute('aria-expanded', String(open));
|
||
});
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 3: ContactForm.astro**
|
||
|
||
```astro
|
||
---
|
||
interface Props { variant?: 'full' | 'footer' }
|
||
const { variant = 'full' } = Astro.props;
|
||
---
|
||
<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate>
|
||
<input class="field" name="firstName" placeholder="Nome" required maxlength="100" />
|
||
{variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />}
|
||
{variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />}
|
||
<input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" />
|
||
<textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea>
|
||
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
|
||
<button class="btn" type="submit">Invia messaggio</button>
|
||
<p class="form-msg" hidden></p>
|
||
</form>
|
||
|
||
<script>
|
||
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
|
||
const btn = form.querySelector<HTMLButtonElement>('button')!;
|
||
btn.disabled = true;
|
||
msg.hidden = true;
|
||
try {
|
||
const res = await fetch('/api/contact', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||
});
|
||
const data = await res.json();
|
||
msg.textContent = res.ok ? 'Messaggio inviato, ti ricontatteremo al più presto.' : (data.error ?? 'Errore di invio, riprova più tardi.');
|
||
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
|
||
if (res.ok) form.reset();
|
||
} catch {
|
||
msg.textContent = 'Errore di rete, riprova più tardi.';
|
||
msg.className = 'form-msg form-msg--err';
|
||
}
|
||
msg.hidden = false;
|
||
btn.disabled = false;
|
||
});
|
||
});
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 4: Footer.astro**
|
||
|
||
Quattro colonne come da Figma (Homepage.pdf, fondo `--c-dark`): brand (logo light, tagline "PERFORMANCE. BALANCE. LONGEVITY.", testo breve, telefono, indirizzo, orari), colonna TRAINING (One to one, Small groups, Performance class, Fit remote → `/training`), colonna PROGRAMMI (le 6 voci → `/programs/<slug>`), colonna CONTACT con `<ContactForm variant="footer" />`. Barra finale: `© {new Date().getFullYear()} Insanitylab, All Rights Reserved` + "Seguici" con link social da `site.socials`.
|
||
|
||
```astro
|
||
---
|
||
import { site } from '../data/site';
|
||
import ContactForm from './ContactForm.astro';
|
||
const year = new Date().getFullYear();
|
||
---
|
||
<footer class="ftr">
|
||
<div class="container ftr__grid">
|
||
<div>
|
||
<img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" />
|
||
<p class="ftr__tag">PERFORMANCE. BALANCE. LONGEVITY.</p>
|
||
<p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p>
|
||
<p>{site.phone}<br />{site.address}, {site.cityLine}</p>
|
||
{site.hours.map((h) => <p class="ftr__hours">{h}</p>)}
|
||
</div>
|
||
<nav aria-label="training">
|
||
<h4>Training</h4>
|
||
{site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)}
|
||
</nav>
|
||
<nav aria-label="programmi">
|
||
<h4>Programmi</h4>
|
||
{site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)}
|
||
</nav>
|
||
<div>
|
||
<h4>Contact</h4>
|
||
<ContactForm variant="footer" />
|
||
</div>
|
||
</div>
|
||
<div class="ftr__bar">
|
||
<div class="container ftr__bar-in">
|
||
<p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p>
|
||
<p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<style>
|
||
.ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
|
||
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
|
||
.ftr a:hover { color: #fff; }
|
||
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
|
||
.ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
|
||
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
|
||
.ftr__hours { margin: 0; }
|
||
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
|
||
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||
.ftr__bar-in a { display: inline; margin-left: 12px; }
|
||
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } }
|
||
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 5: 404.astro**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
export const prerender = true;
|
||
---
|
||
<Base title="Pagina non trovata">
|
||
<section class="section" style="text-align:center; padding-block:140px;">
|
||
<div class="container">
|
||
<p class="eyebrow">Errore 404</p>
|
||
<h1>Pagina non trovata</h1>
|
||
<p>La pagina che cerchi non esiste o è stata spostata.</p>
|
||
<a class="btn" href="/">Torna alla home</a>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
```
|
||
|
||
- [ ] **Step 6: aggiorna index placeholder per usare Base**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
export const prerender = true;
|
||
---
|
||
<Base title="Performance. Balance. Longevity.">
|
||
<section class="section"><div class="container"><h1>Homepage in costruzione</h1></div></section>
|
||
</Base>
|
||
```
|
||
|
||
- [ ] **Step 7: verifica visiva e build**
|
||
|
||
Run: `npm run dev` e apri `http://localhost:4321/` — header sticky con nav, footer scuro 4 colonne, pagina 404 su URL inesistente. Poi `npm run build`.
|
||
Expected: nessun errore; nav mobile funzionante sotto 992px.
|
||
|
||
- [ ] **Step 8: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: layout base con header, footer e 404"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Dati statici dei contenuti
|
||
|
||
**Files:**
|
||
- Create: `src/data/site.ts`, `src/data/programs.ts`, `src/data/trainings.ts`, `src/data/agenda.ts`, `src/data/team.ts`, `src/data/method.ts`, `src/data/pricing.ts`, `src/data/partners.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: export nominati usati dalle pagine. Testi reali dai PDF Figma (riportati sotto). Nessun lorem ipsum salvo dove il Figma stesso è placeholder (agenda, che riusa i nomi del template).
|
||
|
||
- [ ] **Step 1: site.ts**
|
||
|
||
```ts
|
||
export const site = {
|
||
name: 'InsanityLab',
|
||
phone: '351 7535977',
|
||
email: 'info@insanitylab.it',
|
||
address: 'Via Leandro Alberti, 76',
|
||
cityLine: '40139 Bologna',
|
||
hours: ['Lun – Ven 6.00 – 21.00', 'Sab 8.00 – 19.00 · Dom chiuso'],
|
||
socials: [
|
||
{ label: 'Instagram', url: 'https://www.instagram.com/insanitylab' },
|
||
{ label: 'Facebook', url: 'https://www.facebook.com/insanitylab' },
|
||
{ label: 'LinkedIn', url: 'https://www.linkedin.com/company/insanitylab' },
|
||
],
|
||
navigation: [
|
||
{ label: 'Insanitylab', href: '/about' },
|
||
{ label: 'Training', href: '/training' },
|
||
{ label: 'Programmi', href: '/programs' },
|
||
{ label: 'Blog & Edugo', href: '/blog' },
|
||
{ label: 'Contatti', href: '/contact' },
|
||
],
|
||
footerTraining: [
|
||
{ label: 'One to one', href: '/training#one-to-one' },
|
||
{ label: 'Small groups', href: '/training#small-groups' },
|
||
{ label: 'Performance class', href: '/training#performance-class' },
|
||
{ label: 'Fit remote', href: '/training#fit-remote' },
|
||
],
|
||
footerPrograms: [
|
||
{ label: 'Personal Training', href: '/programs/personal-training' },
|
||
{ label: 'Performance Class', href: '/programs/performance-class' },
|
||
{ label: 'Coaching', href: '/programs/coaching' },
|
||
{ label: 'Rehab', href: '/programs/rehab' },
|
||
{ label: 'Pilates Flow', href: '/programs/pilates-flow' },
|
||
{ label: 'Nutrizione', href: '/programs/nutrition' },
|
||
],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: programs.ts**
|
||
|
||
Excerpt = testi reali di Programmi.pdf. `longDescription` e `faq`: per `performance-class` dai testi di Performance class.pdf; per gli altri derivati dall'excerpt e da Training.pdf (il cliente li integrerà — testo comunque sensato, niente lorem).
|
||
|
||
```ts
|
||
export interface Program {
|
||
slug: string;
|
||
title: string;
|
||
subtitle: string;
|
||
excerpt: string;
|
||
longDescription: string;
|
||
features: string[];
|
||
faq: { q: string; a: string }[];
|
||
hasPricing: boolean;
|
||
}
|
||
|
||
export const programs: Program[] = [
|
||
{
|
||
slug: 'personal-training',
|
||
title: 'Personal Training',
|
||
subtitle: 'One to One / Small Group',
|
||
excerpt: 'Il personal training InsanityLab è un percorso one-to-one su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.',
|
||
longDescription: 'Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati. Disponibile anche in formula small group (massimo 3 persone) per unire attenzione personalizzata, motivazione e condivisione.',
|
||
features: ['Trainer dedicato', 'Percorso su misura', 'Valutazione iniziale e monitoraggio', 'Formula one to one o small group (max 3)'],
|
||
faq: [
|
||
{ q: 'Come si svolge la prima seduta?', a: 'Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto.' },
|
||
{ q: 'Con quale frequenza ci si allena?', a: 'La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza.' },
|
||
{ q: 'È adatto a chi parte da zero?', a: 'Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante.' },
|
||
],
|
||
hasPricing: false,
|
||
},
|
||
{
|
||
slug: 'performance-class',
|
||
title: 'Performance Class',
|
||
subtitle: 'Gruppo max 8 persone',
|
||
excerpt: 'La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.',
|
||
longDescription: 'La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.',
|
||
features: ['Allenamento ibrido', 'Forza, ipertrofia, condizionamento metabolico e lavoro funzionale', 'Sessioni da 50 minuti', 'Fasi di bassa, media e alta intensità', 'Programmazione mensile'],
|
||
faq: [
|
||
{ q: 'Che cosa si intende per Performance Class?', a: 'Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio.' },
|
||
{ q: 'Che tipo di allenamento viene svolto?', a: 'Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile.' },
|
||
{ q: 'A chi è consigliato questo servizio?', a: 'A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile.' },
|
||
{ q: 'Qual è la differenza rispetto a una class tradizionale?', a: 'La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati.' },
|
||
{ q: 'Che tipo di risultati o miglioramenti si ottengono?', a: 'Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo.' },
|
||
{ q: 'Qual è l’intensità del lavoro?', a: 'Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti.' },
|
||
],
|
||
hasPricing: true,
|
||
},
|
||
{
|
||
slug: 'coaching',
|
||
title: 'Coaching',
|
||
subtitle: 'Programma di allenamento e monitoraggio',
|
||
excerpt: 'Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.',
|
||
longDescription: 'Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).',
|
||
features: ['Programma su misura', 'Supporto e monitoraggio costanti', 'Ovunque ti alleni (Fit Remote)', 'Sviluppo di autonomia'],
|
||
faq: [
|
||
{ q: 'Come funziona il monitoraggio?', a: 'Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati.' },
|
||
{ q: 'Serve attrezzatura?', a: 'Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa.' },
|
||
{ q: 'È adatto se viaggio spesso?', a: 'Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi.' },
|
||
],
|
||
hasPricing: false,
|
||
},
|
||
{
|
||
slug: 'pilates-flow',
|
||
title: 'Pilates Flow',
|
||
subtitle: 'Movimento e controllo',
|
||
excerpt: 'Il Pilates Flow migliora postura, core e consapevolezza corporea con un movimento fluido, mentre l’osteopatia supporta recupero, prevenzione e benessere globale integrandosi con l’allenamento.',
|
||
longDescription: 'Il Pilates Flow unisce movimento fluido e controllo: migliora postura, core e consapevolezza corporea. Integrato con l’osteopatia, supporta recupero, prevenzione e benessere globale all’interno del percorso di allenamento.',
|
||
features: ['Postura e core', 'Movimento fluido e controllo', 'Integrazione con osteopatia', 'Benessere globale'],
|
||
faq: [
|
||
{ q: 'A chi è adatto il Pilates Flow?', a: 'A chiunque voglia migliorare postura, mobilità e controllo del movimento, a ogni livello.' },
|
||
{ q: 'Serve esperienza precedente?', a: 'No: le progressioni sono graduali e seguite da professionisti qualificati.' },
|
||
{ q: 'Come si integra con l’osteopatia?', a: 'Il lavoro osteopatico supporta recupero e prevenzione, in sinergia con le sessioni di movimento.' },
|
||
],
|
||
hasPricing: false,
|
||
},
|
||
{
|
||
slug: 'rehab',
|
||
title: 'Rehab',
|
||
subtitle: 'Recupero post-infortunio, patologie muscolo-scheletriche',
|
||
excerpt: 'Il Rehab InsanityLab è un percorso supervisionato che accompagna dal recupero riabilitativo al ritorno all’attività fisica in modo sicuro, progressivo e personalizzato.',
|
||
longDescription: 'Il Rehab accompagna dal recupero post-infortunio o da patologie muscolo-scheletriche fino al ritorno all’attività fisica. Percorso supervisionato da chinesiologi e osteopati, sicuro, progressivo e personalizzato.',
|
||
features: ['Recupero post-infortunio', 'Patologie muscolo-scheletriche', 'Supervisione di chinesiologi e osteopati', 'Progressione sicura e personalizzata'],
|
||
faq: [
|
||
{ q: 'Chi segue il percorso Rehab?', a: 'Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con l’area osteopatica.' },
|
||
{ q: 'Serve una diagnosi medica?', a: 'Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari.' },
|
||
{ q: 'Quanto dura il percorso?', a: 'Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso.' },
|
||
],
|
||
hasPricing: false,
|
||
},
|
||
{
|
||
slug: 'nutrition',
|
||
title: 'Nutrizione',
|
||
subtitle: 'Alimentazione e stile di vita',
|
||
excerpt: 'La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.',
|
||
longDescription: 'Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.',
|
||
features: ['Piani alimentari su misura', 'Integrazione con l’allenamento', 'Sostenibilità nel tempo', 'Focus su energia e performance'],
|
||
faq: [
|
||
{ q: 'Come inizia il percorso di nutrizione?', a: 'Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato.' },
|
||
{ q: 'È integrato con il training?', a: 'Sì: alimentazione e allenamento vengono programmati in modo coerente.' },
|
||
{ q: 'Sono previsti controlli periodici?', a: 'Sì, con aggiornamento del piano in base ai risultati.' },
|
||
],
|
||
hasPricing: false,
|
||
},
|
||
];
|
||
|
||
export function getProgram(slug: string): Program | undefined {
|
||
return programs.find((p) => p.slug === slug);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: trainings.ts (4 card Training)**
|
||
|
||
```ts
|
||
export const trainings = [
|
||
{ id: 'one-to-one', title: 'One to One', caption: 'Allenamento con un trainer dedicato', image: 'training-one-to-one', description: 'Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.', cta: { label: 'Contattaci', href: '/contact' } },
|
||
{ id: 'small-groups', title: 'Small Groups', caption: 'Mini gruppi (max 3 persone) con trainer dedicato', image: 'training-small-groups', description: 'Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.', cta: { label: 'Contattaci', href: '/contact' } },
|
||
{ id: 'performance-class', title: 'Performance Class', caption: 'Classi fino a 8 persone con trainer certificato', image: 'training-performance-class', description: 'Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.', cta: { label: 'Scopri', href: '/programs/performance-class' } },
|
||
{ id: 'fit-remote', title: 'Fit Remote', caption: 'Piano di allenamento su misura, ovunque ti alleni.', image: 'training-fit-remote', description: 'Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.', cta: { label: 'Contattaci', href: '/contact' } },
|
||
] as const;
|
||
```
|
||
|
||
- [ ] **Step 4: agenda.ts (orario placeholder dal template, come nel Figma)**
|
||
|
||
```ts
|
||
export const disciplines = ['Events', 'Crossfit', 'Cardio', 'Box', 'Meditation', 'Yoga Classes', 'Body Balance'] as const;
|
||
export const days = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'] as const;
|
||
export const slots = ['10.00', '12.00', '16.00', '18.00', '20.00'] as const;
|
||
|
||
export interface AgendaEntry { day: number; slot: string; discipline: string; time: string; trainer: string }
|
||
|
||
// Placeholder dal template Prowess (come nel Figma); il cliente fornirà l'orario reale.
|
||
export const agenda: AgendaEntry[] = [
|
||
{ day: 0, slot: '10.00', discipline: 'Crossfit', time: '10.00 - 11.00', trainer: 'Amanda Snow' },
|
||
{ day: 0, slot: '12.00', discipline: 'Box', time: '12.00 - 13.00', trainer: 'Matthew Dunn' },
|
||
{ day: 0, slot: '18.00', discipline: 'Cardio', time: '18.00 - 19.00', trainer: 'Randy Nelson' },
|
||
{ day: 0, slot: '20.00', discipline: 'Crossfit', time: '20.00 - 21.00', trainer: 'Amanda Snow' },
|
||
{ day: 1, slot: '10.00', discipline: 'Cardio', time: '10.00 - 11.00', trainer: 'Randy Nelson' },
|
||
{ day: 1, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' },
|
||
{ day: 1, slot: '16.00', discipline: 'Body Balance', time: '16.00 - 17.00', trainer: 'Barbara Holland' },
|
||
{ day: 1, slot: '20.00', discipline: 'Box', time: '20.00 - 21.00', trainer: 'Matthew Dunn' },
|
||
{ day: 2, slot: '10.00', discipline: 'Crossfit', time: '10.00 - 11.00', trainer: 'Amanda Snow' },
|
||
{ day: 2, slot: '12.00', discipline: 'Meditation', time: '12.00 - 13.00', trainer: 'Nicole Fuller' },
|
||
{ day: 2, slot: '16.00', discipline: 'Box', time: '16.00 - 17.00', trainer: 'Matthew Dunn' },
|
||
{ day: 2, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Barbara Holland' },
|
||
{ day: 3, slot: '10.00', discipline: 'Body Balance', time: '10.00 - 11.00', trainer: 'Barbara Holland' },
|
||
{ day: 3, slot: '12.00', discipline: 'Box', time: '12.00 - 13.00', trainer: 'Matthew Dunn' },
|
||
{ day: 3, slot: '16.00', discipline: 'Crossfit', time: '16.00 - 17.00', trainer: 'Amanda Snow' },
|
||
{ day: 3, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Randy Nelson' },
|
||
{ day: 4, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' },
|
||
{ day: 4, slot: '16.00', discipline: 'Body Balance', time: '16.00 - 17.00', trainer: 'Barbara Holland' },
|
||
{ day: 4, slot: '18.00', discipline: 'Cardio', time: '18.00 - 19.00', trainer: 'Randy Nelson' },
|
||
{ day: 4, slot: '20.00', discipline: 'Yoga Classes', time: '20.00 - 21.00', trainer: 'Amanda Snow' },
|
||
{ day: 5, slot: '10.00', discipline: 'Cardio', time: '10.00 - 11.00', trainer: 'Randy Nelson' },
|
||
{ day: 5, slot: '12.00', discipline: 'Body Balance', time: '12.00 - 13.00', trainer: 'Barbara Holland' },
|
||
{ day: 5, slot: '18.00', discipline: 'Box', time: '18.00 - 19.00', trainer: 'Matthew Dunn' },
|
||
{ day: 6, slot: '12.00', discipline: 'Crossfit', time: '12.00 - 13.00', trainer: 'Amanda Snow' },
|
||
{ day: 6, slot: '16.00', discipline: 'Cardio', time: '16.00 - 17.00', trainer: 'Randy Nelson' },
|
||
{ day: 6, slot: '18.00', discipline: 'Box', time: '18.00 - 19.00', trainer: 'Matthew Dunn' },
|
||
{ day: 6, slot: '20.00', discipline: 'Body Balance', time: '20.00 - 21.00', trainer: 'Barbara Holland' },
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 5: team.ts**
|
||
|
||
```ts
|
||
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; founder: boolean }
|
||
|
||
export const team: Member[] = [
|
||
{ name: 'Donata Dal Pastro', shortName: 'Donata', role: 'Chinesiologa e Osteopata', founder: true, image: 'team-donata',
|
||
bio: 'Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona l’area dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza.' },
|
||
{ name: 'Nicola Antonelli', shortName: 'Nicola', role: 'Chinesiologo e Osteopata', founder: true, image: 'team-nicola',
|
||
bio: 'Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e all’allenamento avanzato.' },
|
||
{ name: 'Eva Cassetta', shortName: 'Eva', role: 'Business Manager – Balance & Strategy', founder: true,
|
||
bio: 'Eva Cassetta ricopre il ruolo di Business Manager – Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo.' },
|
||
{ name: 'Alessandro Lazzari', shortName: 'Alessandro', role: 'Trainer – Functional & Movement', founder: false,
|
||
bio: 'Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera.' },
|
||
{ name: 'Eleonora Cassani', shortName: 'Eleonora', role: 'Trainer – Planning Manager', founder: false, image: 'team-eleonora',
|
||
bio: 'Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza.' },
|
||
{ name: "Antonello D'Alessandro", shortName: 'Antonello', role: 'Trainer – Exercise & Health', founder: false, image: 'team-antonello',
|
||
bio: 'Trainer – Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia.' },
|
||
{ name: 'Alice Roda', shortName: 'Alice', role: 'Trainer – Functional & Movement', founder: false,
|
||
bio: 'Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso.' },
|
||
{ name: 'Davide Bisagni', shortName: 'Davide', role: 'Trainer – Functional & Movement', founder: false,
|
||
bio: 'Trainer – Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature.' },
|
||
{ name: 'Giulia Barbàra', shortName: 'Giulia', role: 'Trainer – Pilates, Yoga & Mindfulness', founder: false,
|
||
bio: 'Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo.' },
|
||
];
|
||
|
||
export const homeTeam = ['Donata', 'Nicola', 'Eleonora', 'Antonello'];
|
||
```
|
||
|
||
- [ ] **Step 6: method.ts, pricing.ts, partners.ts**
|
||
|
||
`src/data/method.ts`:
|
||
```ts
|
||
export const methodIntro = 'Il Metodo Insanitylab si basa su un percorso strutturato in fasi, dove le prime due rappresentano il focus standard del lavoro: valutazione iniziale e avvio dell’allenamento personalizzato. A queste possono essere aggiunti servizi e approfondimenti extra, in base alle esigenze e agli obiettivi della persona.';
|
||
export const methodExtraNote = 'Disponibili su richiesta, non incluse nel percorso standard.';
|
||
|
||
export const methodSteps = [
|
||
{ n: 1, title: 'Prima consulenza conoscitiva iniziale', text: 'Dove raccogliamo un’anamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', extra: false, image: 'method-step-1' },
|
||
{ n: 2, title: 'Prima seduta operativa', text: 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', extra: false, image: 'method-step-2' },
|
||
{ n: 3, title: 'Plicometria', text: 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', extra: true, image: 'method-step-3' },
|
||
{ n: 4, title: 'Analisi antropometriche', text: 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', extra: true, image: 'method-step-4' },
|
||
];
|
||
```
|
||
|
||
`src/data/pricing.ts` (prezzi da Performance class.pdf):
|
||
```ts
|
||
export const performanceClassPricing = {
|
||
heading: 'Acquista la tua performance class',
|
||
intro: 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.',
|
||
groups: [
|
||
{
|
||
label: 'Programmi performance class semestrale',
|
||
open: true,
|
||
plans: [
|
||
{ monthly: 130, entries: 1, note: 'tot: 780€ · da 23€ lezione singola', installments: '2 rate da 390€ – inizio 60', best: 'best price 720€' },
|
||
{ monthly: 234, entries: 2, note: 'tot: 1.400€ · da 23€ lezione singola', installments: '2 rate da 700€ – inizio 60', best: 'best price 1.250€' },
|
||
{ monthly: 300, entries: 3, note: 'tot: 1.800€ · da 23€ lezione singola', installments: '2 rate da 900€ – inizio 60', best: 'best price 1.650€' },
|
||
],
|
||
},
|
||
{ label: 'Programmi performance class trimestrale', open: false, plans: [] },
|
||
{ label: 'Programmi performance class mensile', open: false, plans: [] },
|
||
],
|
||
};
|
||
```
|
||
(I gruppi senza piani mostrano "Contattaci per i dettagli" — nel Figma sono accordion chiusi senza contenuto visibile.)
|
||
|
||
`src/data/partners.ts`:
|
||
```ts
|
||
export const partners = [
|
||
{ name: 'Ticket Welfare Edenred', image: 'partner-edenred' },
|
||
{ name: 'Welbee', image: 'partner-welbee' },
|
||
{ name: 'AON', image: 'partner-aon' },
|
||
{ name: 'Howden', image: 'partner-howden' },
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 7: verifica typecheck e commit**
|
||
|
||
Run: `npx astro check 2>&1 | tail -5` (o `npx tsc --noEmit`)
|
||
Expected: nessun errore sui file dati.
|
||
|
||
```bash
|
||
git add src/data && git commit -m "feat: dati statici contenuti da design Figma"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Homepage
|
||
|
||
**Files:**
|
||
- Create: `src/components/home/HeroSlider.astro`, `src/components/home/InfoColumns.astro`, `src/components/home/FeatureBlocks.astro`, `src/components/home/QuoteBanner.astro`, `src/components/home/TrainingCards.astro`, `src/components/home/ProgramsGrid.astro`, `src/components/Agenda.astro`, `src/components/home/TeamSection.astro`, `src/components/home/MethodSteps.astro`, `src/components/home/PartnersStrip.astro`
|
||
- Modify: `src/pages/index.astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: dati Task 8, immagini Task 2 (`src/assets/img/*` via `astro:assets`), utility CSS Task 3.
|
||
- Produces: `<Agenda />` riusato dal Task 11; `<TrainingCards />` riusato dal Task 11.
|
||
|
||
Riferimento visivo: `../FigmaDoc/Homepage.pdf`. Per confronto genera le fette: `pdftoppm -png -r 50 ../FigmaDoc/Homepage.pdf /tmp/ilab-home && convert /tmp/ilab-home-1.png -crop x1800 +repage /tmp/ilab-home-slice_%d.png`.
|
||
|
||
- [ ] **Step 1: HeroSlider.astro**
|
||
|
||
Hero fullscreen: titolo `PERFORMANCE.` / `BALANCE.` / `LONGEVITY.` su righe, bottone SCOPRI → `/programs`, immagine hero a destra, pattern di linee orizzontali a sinistra, frecce navigazione. Il Figma mostra una sola slide compiuta: implementare struttura a slide con array (1 slide ora, estendibile).
|
||
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import hero1 from '../../assets/img/hero-1.jpg';
|
||
const slides = [
|
||
{ title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' },
|
||
];
|
||
---
|
||
<section class="hero" aria-label="presentazione">
|
||
{slides.map((s, i) => (
|
||
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
|
||
<div class="container hero__in">
|
||
<div class="hero__text">
|
||
<h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
|
||
<a class="btn" href="/programs">Scopri</a>
|
||
</div>
|
||
<Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
{slides.length > 1 && (
|
||
<>
|
||
<button class="hero__arrow hero__arrow--prev" aria-label="precedente">‹</button>
|
||
<button class="hero__arrow hero__arrow--next" aria-label="successiva">›</button>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
<style>
|
||
.hero { position: relative; background:
|
||
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
|
||
.hero__slide { display: none; }
|
||
.hero__slide.is-active { display: block; }
|
||
.hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
|
||
.hero__line { display: block; }
|
||
.hero__text h1 { font-weight: 500; }
|
||
.hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); }
|
||
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
|
||
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
|
||
@media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
|
||
</style>
|
||
|
||
<script>
|
||
const slides = document.querySelectorAll('.hero__slide');
|
||
if (slides.length > 1) {
|
||
let cur = 0;
|
||
const show = (i: number) => {
|
||
slides[cur].classList.remove('is-active');
|
||
cur = (i + slides.length) % slides.length;
|
||
slides[cur].classList.add('is-active');
|
||
};
|
||
document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
|
||
document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 2: InfoColumns.astro**
|
||
|
||
Tre colonne full-width con foto di sfondo e overlay scuro, titoli `PERFORMANCE.` `BALANCE.` `LONGEVITY.`; ogni colonna linka `/programs`.
|
||
|
||
```astro
|
||
---
|
||
import infoPerformance from '../../assets/img/info-performance.jpg';
|
||
import infoBalance from '../../assets/img/info-balance.jpg';
|
||
import infoLongevity from '../../assets/img/info-longevity.jpg';
|
||
const cols = [
|
||
{ title: 'PERFORMANCE.', image: infoPerformance },
|
||
{ title: 'BALANCE.', image: infoBalance },
|
||
{ title: 'LONGEVITY.', image: infoLongevity },
|
||
];
|
||
---
|
||
<section class="info" aria-label="pilastri">
|
||
{cols.map((c) => (
|
||
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}>
|
||
<span class="info__title">{c.title}</span>
|
||
</a>
|
||
))}
|
||
</section>
|
||
|
||
<style>
|
||
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||
.info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
|
||
.info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
|
||
.info__col:hover::before { background: rgba(30,24,20,.15); }
|
||
.info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
|
||
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: FeatureBlocks.astro**
|
||
|
||
Tre blocchi alternati testo/immagine (testi reali da Homepage.pdf):
|
||
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import featPerformance from '../../assets/img/feature-performance.jpg';
|
||
import featBalance from '../../assets/img/feature-balance.jpg';
|
||
import featLongevity from '../../assets/img/feature-longevity.jpg';
|
||
const blocks = [
|
||
{ title: 'PERFORMANCE', image: featPerformance, reverse: false,
|
||
text: 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.' },
|
||
{ title: 'BALANCE', image: featBalance, reverse: true,
|
||
text: 'L’equilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.' },
|
||
{ title: 'LONGEVITY', image: featLongevity, reverse: false,
|
||
text: 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.' },
|
||
];
|
||
---
|
||
<section class="section">
|
||
<div class="container">
|
||
{blocks.map((b) => (
|
||
<div class:list={['feat', { 'feat--reverse': b.reverse }]}>
|
||
<Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" />
|
||
<div class="feat__text">
|
||
<h2>{b.title}</h2>
|
||
<p>{b.text}</p>
|
||
<a class="btn" href="/programs">Scopri</a>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.feat { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; }
|
||
.feat--reverse .feat__img { order: 2; }
|
||
.feat__text h2 { text-transform: uppercase; letter-spacing: .1em; }
|
||
@media (max-width: 767px) { .feat { grid-template-columns: 1fr; gap: 24px; } .feat--reverse .feat__img { order: 0; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: QuoteBanner.astro**
|
||
|
||
Banner scuro con foto di sfondo e citazione (nel Figma non è marquee ma banner statico con virgolette):
|
||
|
||
```astro
|
||
---
|
||
import quoteBg from '../../assets/img/quote-bg.jpg';
|
||
---
|
||
<section class="quote" style={`background-image:url(${quoteBg.src})`}>
|
||
<div class="container quote__in">
|
||
<p class="quote__mark">“</p>
|
||
<blockquote>
|
||
<p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p>
|
||
<cite>Insanitylab</cite>
|
||
</blockquote>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.quote { position: relative; background-size: cover; background-position: center; text-align: center; }
|
||
.quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
|
||
.quote__in { position: relative; padding-block: 110px; max-width: 820px; }
|
||
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
|
||
.quote blockquote { margin: 0; }
|
||
.quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
|
||
.quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 5: TrainingCards.astro**
|
||
|
||
Griglia 2×4 in alternanza card testo (scura/tan) e card foto, dati da `trainings.ts` (riusato in `/training`):
|
||
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import { trainings } from '../../data/trainings';
|
||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||
const tones = ['dark', 'tan', 'dark', 'tan'];
|
||
---
|
||
<section class="tcards">
|
||
{trainings.map((t, i) => (
|
||
<>
|
||
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`, { 'tcards__cell--swap': i % 2 === 1 }]}>
|
||
<h3>{t.title}</h3>
|
||
<p>{t.caption}</p>
|
||
<a class="tcards__link" href={t.cta.href}>Scopri →</a>
|
||
</div>
|
||
{imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />}
|
||
</>
|
||
))}
|
||
</section>
|
||
|
||
<style>
|
||
.tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||
.tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; }
|
||
.tcards__cell--dark { background: var(--c-dark); }
|
||
.tcards__cell--dark h3, .tcards__cell--dark p { color: #efe9e3; }
|
||
.tcards__cell--tan { background: var(--c-accent); }
|
||
.tcards__cell--tan h3, .tcards__cell--tan p { color: #fff; }
|
||
.tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
|
||
.tcards__cell p { font-size: .88rem; }
|
||
.tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
|
||
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; }
|
||
/* alterna ordine nella riga 2: foto prima della card */
|
||
.tcards__cell--swap { order: 1; }
|
||
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } .tcards__cell--swap { order: 0; } }
|
||
</style>
|
||
```
|
||
(Verifica l'alternanza card/foto contro il PDF: riga 1 = card, foto, card, foto; riga 2 = foto, card, foto, card. Correggi con `order` finché combacia.)
|
||
|
||
- [ ] **Step 6: ProgramsGrid.astro**
|
||
|
||
Intestazione "PROGRAMMI" + griglia 3×2 di voci con icona linea, titolo e sottotitolo, link alla pagina dettaglio:
|
||
|
||
```astro
|
||
---
|
||
import { programs } from '../../data/programs';
|
||
const icons: Record<string, string> = {
|
||
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
|
||
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
|
||
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
|
||
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
|
||
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
|
||
};
|
||
---
|
||
<section class="section">
|
||
<div class="container">
|
||
<div class="section-heading">
|
||
<h2>Programmi</h2>
|
||
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
|
||
</div>
|
||
<div class="pgrid">
|
||
{programs.map((p) => (
|
||
<a class="pgrid__item" href={`/programs/${p.slug}`}>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
|
||
<h3>{p.title}</h3>
|
||
<p>{p.subtitle}</p>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
|
||
.pgrid__item { text-decoration: none; }
|
||
.pgrid__item svg { margin-inline: auto; }
|
||
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
|
||
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
|
||
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
|
||
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
(Se nell'archivio Figma sono presenti le icone originali dei programmi, usale al posto degli SVG inline: mappale nel Task 2 come `icon-<slug>.png`.)
|
||
|
||
- [ ] **Step 7: Agenda.astro (condiviso)**
|
||
|
||
Sezione scura con tab discipline e griglia settimanale:
|
||
|
||
```astro
|
||
---
|
||
import { agenda, disciplines, days, slots } from '../data/agenda';
|
||
---
|
||
<section class="section section--dark agenda">
|
||
<div class="container">
|
||
<div class="section-heading"><h2>Agenda</h2></div>
|
||
<div class="agenda__tabs" role="tablist">
|
||
{disciplines.map((d, i) => (
|
||
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
|
||
))}
|
||
</div>
|
||
<div class="agenda__grid">
|
||
<div class="agenda__corner"></div>
|
||
{days.map((d) => <div class="agenda__day">{d}</div>)}
|
||
{slots.map((slot) => (
|
||
<>
|
||
<div class="agenda__slot">{slot}</div>
|
||
{days.map((_, dayIdx) => {
|
||
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
|
||
return entry
|
||
? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
|
||
: <div class="agenda__cell agenda__cell--empty"></div>;
|
||
})}
|
||
</>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
|
||
.agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
|
||
.agenda__tab.is-active { background: var(--c-accent); color: #fff; }
|
||
.agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
|
||
.agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
|
||
.agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
|
||
.agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
|
||
.agenda__cell em { font-style: normal; opacity: .85; }
|
||
.agenda__cell--empty { background: transparent; }
|
||
.agenda__cell.is-dim { opacity: .15; }
|
||
@media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
|
||
</style>
|
||
|
||
<script>
|
||
document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
|
||
const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
|
||
const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
|
||
tabs.forEach((tab) => tab.addEventListener('click', () => {
|
||
tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
|
||
const f = tab.dataset.filter!;
|
||
cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
|
||
}));
|
||
});
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 8: TeamSection.astro, MethodSteps.astro, PartnersStrip.astro**
|
||
|
||
`TeamSection.astro` — titolo "TEAM TRAINER" a sinistra, bottone "Scopri team" → `/about`, 4 ritratti con nome verticale sul bordo:
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import { team, homeTeam } from '../../data/team';
|
||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
|
||
const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
|
||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||
---
|
||
<section class="section">
|
||
<div class="container">
|
||
<div class="tteam__head">
|
||
<h2>Team<br />Trainer</h2>
|
||
<a class="btn" href="/about">Scopri team</a>
|
||
</div>
|
||
<div class="tteam__grid">
|
||
{members.map((m) => (
|
||
<figure class="tteam__card">
|
||
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||
<figcaption>{m.shortName}</figcaption>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
|
||
.tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
|
||
.tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||
.tteam__card { position: relative; margin: 0; }
|
||
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; }
|
||
.tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
|
||
@media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
`MethodSteps.astro` — intestazione "IL METODO INSANITYLAB" + 4 cerchi numerati con linea tratteggiata; nota extra sopra gli step 3–4:
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import { methodIntro, methodExtraNote, methodSteps } from '../../data/method';
|
||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
|
||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||
---
|
||
<section class="section section--alt">
|
||
<div class="container">
|
||
<div class="section-heading">
|
||
<h2>Il metodo<br />InsanityLab</h2>
|
||
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
|
||
</div>
|
||
<p class="method__note">{methodExtraNote}</p>
|
||
<div class="method__row">
|
||
{methodSteps.map((s) => (
|
||
<div class:list={['method__step', { 'method__step--extra': s.extra }]}>
|
||
<div class="method__circle">
|
||
{imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||
<span>{s.n}</span>
|
||
</div>
|
||
<h3>{s.title}</h3>
|
||
<p>{s.text}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
|
||
.method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
|
||
.method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
|
||
.method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
|
||
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
|
||
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
|
||
.method__step p { font-size: .85rem; color: var(--c-text-light); }
|
||
@media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
`PartnersStrip.astro` — striscia tan con i 4 loghi:
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import { partners } from '../../data/partners';
|
||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
|
||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||
---
|
||
<section class="pstrip" aria-label="partner">
|
||
<div class="container pstrip__in">
|
||
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.pstrip { background: var(--c-accent); padding-block: 40px; }
|
||
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
|
||
.pstrip img { height: 44px; width: auto; opacity: .9; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 9: componi index.astro**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
import HeroSlider from '../components/home/HeroSlider.astro';
|
||
import InfoColumns from '../components/home/InfoColumns.astro';
|
||
import FeatureBlocks from '../components/home/FeatureBlocks.astro';
|
||
import QuoteBanner from '../components/home/QuoteBanner.astro';
|
||
import TrainingCards from '../components/home/TrainingCards.astro';
|
||
import ProgramsGrid from '../components/home/ProgramsGrid.astro';
|
||
import Agenda from '../components/Agenda.astro';
|
||
import TeamSection from '../components/home/TeamSection.astro';
|
||
import MethodSteps from '../components/home/MethodSteps.astro';
|
||
import PartnersStrip from '../components/home/PartnersStrip.astro';
|
||
export const prerender = true;
|
||
---
|
||
<Base title="Performance. Balance. Longevity.">
|
||
<HeroSlider />
|
||
<InfoColumns />
|
||
<FeatureBlocks />
|
||
<QuoteBanner />
|
||
<section class="section" style="padding-bottom:0">
|
||
<div class="section-heading">
|
||
<h2>Training</h2>
|
||
<p>Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.</p>
|
||
</div>
|
||
</section>
|
||
<TrainingCards />
|
||
<ProgramsGrid />
|
||
<Agenda />
|
||
<TeamSection />
|
||
<MethodSteps />
|
||
<PartnersStrip />
|
||
</Base>
|
||
```
|
||
|
||
- [ ] **Step 10: confronto visivo con Figma**
|
||
|
||
Run: `npm run dev`, apri `http://localhost:4321/` e confronta sezione per sezione con `/tmp/ilab-home-slice_*.png`. Correggi spaziature, dimensioni titoli, colori finché la corrispondenza è fedele (non pixel-perfect: stessa gerarchia, palette, proporzioni). Verifica mobile a 375px.
|
||
|
||
- [ ] **Step 11: build e commit**
|
||
|
||
Run: `npm run build`
|
||
Expected: build ok.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: homepage completa da design Figma"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Pagina About (`/about`)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/about.astro`, `src/components/about/Timeline.astro`, `src/components/PageHero.astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `team` (Task 8), immagini `about-*`/`team-*` (Task 2), `site`.
|
||
- Produces: `<PageHero title />` riusato da training/programs/blog/contact (banner titolo pagina su fondo chiaro con eyebrow).
|
||
|
||
Riferimento: `../FigmaDoc/Insanitylab.pdf` (generare fette come per la homepage). Sezioni nell'ordine: hero titolo "Insanitylab", Mission, Direzione (+ bottone Scopri), Approccio (+ bottone Contattaci), About, Storylab (timeline), Convenzioni, Team (3 fondatori + 6 trainer), Metodo (testo), In-Sanity Studios, Vision.
|
||
|
||
- [ ] **Step 1: PageHero.astro**
|
||
|
||
```astro
|
||
---
|
||
interface Props { title: string; eyebrow?: string }
|
||
const { title, eyebrow } = Astro.props;
|
||
---
|
||
<section class="phero">
|
||
<div class="container">
|
||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||
<h1>{title}</h1>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.phero { background: var(--c-bg-alt); padding-block: 70px; }
|
||
.phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Timeline.astro (Storylab)**
|
||
|
||
Blocco 2021-2024 aperto con testo; 2025, 2026 e OGGI come accordion `<details>`:
|
||
|
||
```astro
|
||
---
|
||
const openText = `Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l’area dedicata alla riabilitazione e all’esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità.`;
|
||
const stages = [
|
||
{ label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
|
||
{ label: 'INSANITYLAB 2026', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
|
||
{ label: 'INSANITYLAB OGGI', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
|
||
];
|
||
---
|
||
<section class="section">
|
||
<div class="container">
|
||
<div class="section-heading">
|
||
<h2>Storylab</h2>
|
||
<p>Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.</p>
|
||
</div>
|
||
<div class="tl">
|
||
<h3 class="tl__label">INSANITYLAB 2021-2024</h3>
|
||
<p>{openText}</p>
|
||
{stages.map((s) => (
|
||
<details class="tl__stage">
|
||
<summary>+ {s.label}</summary>
|
||
<p>{s.text}</p>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.tl { max-width: 720px; margin-inline: auto; }
|
||
.tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
|
||
.tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
|
||
.tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
|
||
.tl__stage summary::-webkit-details-marker { display: none; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: about.astro**
|
||
|
||
Testi reali da Insanitylab.pdf (già estratti — riportati integralmente qui sotto). Struttura: alternanza di sezioni testo/immagine come nel PDF.
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
import PageHero from '../components/PageHero.astro';
|
||
import Timeline from '../components/about/Timeline.astro';
|
||
import { Image } from 'astro:assets';
|
||
import { team } from '../data/team';
|
||
import { site } from '../data/site';
|
||
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true });
|
||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
|
||
const founders = team.filter((m) => m.founder);
|
||
const staff = team.filter((m) => !m.founder);
|
||
export const prerender = true;
|
||
---
|
||
<Base title="Insanitylab" description="Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.">
|
||
<PageHero title="Insanitylab" />
|
||
|
||
<section class="section">
|
||
<div class="container section-heading">
|
||
<h2>Mission</h2>
|
||
<p>In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="section section--alt">
|
||
<div class="container about-2col">
|
||
<div>
|
||
<h2>Direzione</h2>
|
||
<p>Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.</p>
|
||
<a class="btn" href="/programs">Scopri</a>
|
||
</div>
|
||
<div>
|
||
<h2>Approccio</h2>
|
||
<p>InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.</p>
|
||
<a class="btn btn--dark" href="/contact">Contattaci</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="section">
|
||
<div class="container section-heading">
|
||
<h2>About</h2>
|
||
<p>InsanityLab nasce con l’obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all’interno di un approccio personalizzato e consapevole.</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 l’obiettivo di portare il valore del benessere anche all’interno 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à dell’azienda 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 dall’evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse.</p>
|
||
<p>Il progetto nasce con l’obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all’interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente.</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, all’interno 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 l’obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l’estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita.</p>
|
||
<p>L’obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità.</p>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.about-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; }
|
||
.conv { max-width: 760px; margin: 0 auto 30px; display: grid; gap: 18px; padding-left: 20px; }
|
||
.conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
|
||
.tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
|
||
.tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; }
|
||
.tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
|
||
.tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
||
.tcard p { font-size: .9rem; }
|
||
@media (max-width: 767px) { .about-2col, .tgrid { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
Nota: se nell'archivio Figma trovi le immagini delle sezioni about (mission, direzione, in-sanity studios), aggiungi accanto ai testi con lo stesso pattern `Image` — confronta col PDF.
|
||
|
||
- [ ] **Step 2b: i tre accordion Storylab (2025, 2026, OGGI) hanno testo placeholder esplicito "Contenuto in arrivo" — segnala all'operatore nel riepilogo finale del task che servono i testi dal cliente.**
|
||
|
||
- [ ] **Step 4: verifica e commit**
|
||
|
||
Run: `npm run dev`, confronta con le fette di `Insanitylab.pdf`; poi `npm run build`.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: pagina about"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Pagina Training (`/training`)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/training.astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `trainings` (Task 8), `<TrainingCards />`, `<Agenda />` (Task 9), `<PageHero />` (Task 10).
|
||
|
||
Riferimento: `../FigmaDoc/Training.pdf`. Struttura: hero "training", intestazione Training, griglia 4 card (riuso `TrainingCards`), 4 blocchi alternati testo/immagine (one to one, small groups, performance class, fit remote) con ancore `id`, Agenda, CTA "Prenota la lezione di prova".
|
||
|
||
- [ ] **Step 1: training.astro**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
import PageHero from '../components/PageHero.astro';
|
||
import TrainingCards from '../components/home/TrainingCards.astro';
|
||
import Agenda from '../components/Agenda.astro';
|
||
import { Image } from 'astro:assets';
|
||
import { trainings } from '../data/trainings';
|
||
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
|
||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
|
||
export const prerender = true;
|
||
---
|
||
<Base title="Training" description="One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.">
|
||
<PageHero title="training" />
|
||
|
||
<section class="section" style="padding-bottom:0">
|
||
<div class="section-heading">
|
||
<h2>Training</h2>
|
||
<p>Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.</p>
|
||
</div>
|
||
</section>
|
||
<TrainingCards />
|
||
|
||
<section class="section">
|
||
<div class="container">
|
||
{trainings.map((t, i) => (
|
||
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}>
|
||
{imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" />}
|
||
<div>
|
||
<h2>{t.title.toLowerCase()}</h2>
|
||
<p>{t.description}</p>
|
||
<a class="btn" href={t.cta.href}>{t.cta.label}</a>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<Agenda />
|
||
|
||
<section class="section section--alt">
|
||
<div class="container trial">
|
||
<div>
|
||
<h2>Prenota la lezione di prova</h2>
|
||
<p>La lezione di prova è l’occasione perfetta per conoscere il metodo, vivere l’esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova.</p>
|
||
<a class="btn btn--dark" href="/contact">Prenota</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
|
||
.tblock--reverse img { order: 2; }
|
||
.tblock h2 { letter-spacing: .1em; }
|
||
.trial { max-width: 720px; text-align: center; }
|
||
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: verifica e commit**
|
||
|
||
Run: `npm run dev`, confronta con `Training.pdf`; ancore `#one-to-one` ecc. funzionanti dal footer; `npm run build`.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: pagina training"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Pagine Programmi (`/programs`, `/programs/[slug]`)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/programs/index.astro`, `src/pages/programs/[slug].astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `programs`, `getProgram` (Task 8), `performanceClassPricing` (Task 8), `<PageHero />`.
|
||
- Produces: pagine prerenderizzate via `getStaticPaths`.
|
||
|
||
Riferimento: `../FigmaDoc/Programmi.pdf` e `../FigmaDoc/Performance class.pdf`.
|
||
|
||
- [ ] **Step 1: programs/index.astro**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../../layouts/Base.astro';
|
||
import PageHero from '../../components/PageHero.astro';
|
||
import { programs } from '../../data/programs';
|
||
export const prerender = true;
|
||
---
|
||
<Base title="Programmi" description="I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.">
|
||
<PageHero title="programmi" />
|
||
<section class="section">
|
||
<div class="container">
|
||
<div class="section-heading">
|
||
<h2>Programmi</h2>
|
||
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
|
||
</div>
|
||
<div class="plist">
|
||
{programs.map((p) => (
|
||
<article class="plist__card">
|
||
<h3>{p.title}</h3>
|
||
<p class="plist__sub">{p.subtitle}</p>
|
||
<p>{p.excerpt}</p>
|
||
<a class="plist__more" href={`/programs/${p.slug}`}>Per saperne di più →</a>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.plist { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
|
||
.plist__card h3 { letter-spacing: .08em; }
|
||
.plist__sub { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
||
.plist__card p { font-size: .92rem; }
|
||
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||
.plist__more:hover { color: var(--c-accent-dark); }
|
||
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
|
||
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: programs/[slug].astro**
|
||
|
||
Layout da Performance class.pdf: FAQ accordion in alto, blocco descrizione con feature list, sidebar (categorie blog + altri programmi), pricing solo se `hasPricing`.
|
||
|
||
```astro
|
||
---
|
||
import Base from '../../layouts/Base.astro';
|
||
import PageHero from '../../components/PageHero.astro';
|
||
import { programs } from '../../data/programs';
|
||
import { performanceClassPricing } from '../../data/pricing';
|
||
export const prerender = true;
|
||
|
||
export function getStaticPaths() {
|
||
return programs.map((p) => ({ params: { slug: p.slug }, props: { program: p } }));
|
||
}
|
||
const { program } = Astro.props;
|
||
const others = programs.filter((p) => p.slug !== program.slug);
|
||
const blogCategories = ['Fitness', 'Salute', 'Stile di vita', 'Nutrizione'];
|
||
---
|
||
<Base title={program.title} description={program.excerpt}>
|
||
<PageHero title={program.title.toUpperCase()} />
|
||
|
||
<section class="section">
|
||
<div class="container prog">
|
||
<div>
|
||
<h2>{program.title.toUpperCase()}</h2>
|
||
<p>{program.longDescription}</p>
|
||
<ul class="prog__features">
|
||
{program.features.map((f) => <li>{f}</li>)}
|
||
</ul>
|
||
<div class="prog__faq">
|
||
{program.faq.map((item, i) => (
|
||
<details open={i === 0}>
|
||
<summary>{item.q}</summary>
|
||
<p>{item.a}</p>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<aside class="prog__side">
|
||
<h3>Categorie</h3>
|
||
{blogCategories.map((c) => <p><a href="/blog">{c}</a></p>)}
|
||
<h3>Altri programmi</h3>
|
||
{others.map((o) => <p><a href={`/programs/${o.slug}`}>{o.title}</a></p>)}
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
|
||
{program.hasPricing && (
|
||
<section class="section section--alt">
|
||
<div class="container">
|
||
<div class="section-heading">
|
||
<h2>{performanceClassPricing.heading}</h2>
|
||
<p>{performanceClassPricing.intro}</p>
|
||
</div>
|
||
{performanceClassPricing.groups.map((g) => (
|
||
<details class="price__group" open={g.open}>
|
||
<summary>{g.open ? '+' : '–'} {g.label}</summary>
|
||
{g.plans.length === 0 ? (
|
||
<p class="price__empty">Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a></p>
|
||
) : (
|
||
<div class="price__row">
|
||
{g.plans.map((p) => (
|
||
<div class="price__card">
|
||
<p class="price__amount">{p.monthly}€</p>
|
||
<p class="price__per">al mese, semestrale</p>
|
||
<p class="price__entries">{p.entries} {p.entries === 1 ? 'ingresso settimanale' : 'ingressi settimanali'}</p>
|
||
<p>{p.note}</p>
|
||
<p>{p.installments}</p>
|
||
<p class="price__best">{p.best}</p>
|
||
<a class="btn" href="/contact">Acquista</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</Base>
|
||
|
||
<style>
|
||
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
|
||
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
|
||
.prog__features li::marker { color: var(--c-accent); }
|
||
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
|
||
.prog__faq summary { cursor: pointer; font-family: var(--font-heading); font-size: .85rem; letter-spacing: .08em; color: var(--c-heading); }
|
||
.prog__side h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
|
||
.prog__side a { text-decoration: none; color: var(--c-text); }
|
||
.prog__side a:hover { color: var(--c-accent-dark); }
|
||
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
|
||
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
|
||
.price__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-top: 30px; }
|
||
.price__card { background: #fff; padding: 36px 28px; text-align: center; }
|
||
.price__amount { font-family: var(--font-heading); font-size: 2.6rem; color: var(--c-heading); margin: 0; }
|
||
.price__per { color: var(--c-text-light); font-size: .8rem; }
|
||
.price__entries { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .1em; font-size: .82rem; color: var(--c-heading); }
|
||
.price__card p { margin-bottom: 6px; font-size: .85rem; }
|
||
.price__best { font-weight: 600; color: var(--c-accent-dark); }
|
||
.price__card .btn { margin-top: 16px; }
|
||
@media (max-width: 991px) { .prog { grid-template-columns: 1fr; } .price__row { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: verifica e commit**
|
||
|
||
Run: `npm run dev`; controlla `/programs`, `/programs/performance-class` (pricing visibile, 3 card, accordion trimestrale/mensile chiusi), `/programs/rehab` (senza pricing). Confronta con i PDF. `npm run build` — devono comparire 6 pagine `programs/*` prerenderizzate.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: pagine programmi con dettaglio e pricing performance class"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Pagina Contatti e API email (TDD sulla validazione)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/contact.ts`, `src/lib/mailer.ts`, `src/pages/api/contact.ts`, `src/pages/contact.astro`
|
||
- Test: `tests/contact.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `rateLimit` (Task 4), `getEnv` (Task 4), `<ContactForm variant="full" />` (Task 7), `<PageHero />` (Task 10), `site` (Task 8).
|
||
- Produces:
|
||
- `validateContact(data: unknown): { ok: true; value: ContactData } | { ok: false; error: string }` con `ContactData { firstName: string; lastName: string; phone: string; email: string; message: string; website: string }` (campi assenti → stringa vuota).
|
||
- `sendContactEmail(data: ContactData): Promise<void>` (nodemailer).
|
||
- `POST /api/contact` → 200 `{ ok: true }` | 400 `{ error }` | 429 `{ error }` | 502 `{ error }`.
|
||
|
||
- [ ] **Step 1: test validazione (falliranno)**
|
||
|
||
`tests/contact.test.ts`:
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { validateContact } from '../src/lib/contact';
|
||
|
||
describe('validateContact', () => {
|
||
const good = { firstName: 'Mario', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
|
||
|
||
it('accetta payload valido e normalizza i campi opzionali', () => {
|
||
const r = validateContact(good);
|
||
expect(r.ok).toBe(true);
|
||
if (r.ok) {
|
||
expect(r.value.lastName).toBe('');
|
||
expect(r.value.email).toBe('mario@example.com');
|
||
}
|
||
});
|
||
|
||
it('rifiuta email non valida', () => {
|
||
expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false);
|
||
});
|
||
|
||
it('rifiuta campi obbligatori mancanti o vuoti', () => {
|
||
expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
|
||
expect(validateContact({ ...good, message: '' }).ok).toBe(false);
|
||
expect(validateContact(null).ok).toBe(false);
|
||
});
|
||
|
||
it('rifiuta honeypot compilato', () => {
|
||
expect(validateContact({ ...good, website: 'spam.com' }).ok).toBe(false);
|
||
});
|
||
|
||
it('rifiuta messaggi oltre 5000 caratteri', () => {
|
||
expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: verifica che falliscano**
|
||
|
||
Run: `npx vitest run tests/contact.test.ts` — Expected: FAIL.
|
||
|
||
- [ ] **Step 3: implementa contact.ts**
|
||
|
||
`src/lib/contact.ts`:
|
||
```ts
|
||
export interface ContactData {
|
||
firstName: string; lastName: string; phone: string;
|
||
email: string; message: string; website: string;
|
||
}
|
||
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
function str(v: unknown, max: number): string {
|
||
return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
|
||
}
|
||
|
||
export function validateContact(data: unknown):
|
||
| { ok: true; value: ContactData }
|
||
| { ok: false; error: string } {
|
||
if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
|
||
const d = data as Record<string, unknown>;
|
||
const value: ContactData = {
|
||
firstName: str(d.firstName, 100),
|
||
lastName: str(d.lastName, 100),
|
||
phone: str(d.phone, 40),
|
||
email: str(d.email, 200),
|
||
message: str(d.message, 5000),
|
||
website: str(d.website, 200),
|
||
};
|
||
if (value.website) return { ok: false, error: 'Richiesta non valida.' };
|
||
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
|
||
if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
|
||
if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
|
||
if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
|
||
return { ok: true, value };
|
||
}
|
||
```
|
||
Nota: `str()` taglia a `max + 1` così un messaggio oltre il limite non viene accettato silenziosamente ma rifiutato dal check di lunghezza.
|
||
|
||
- [ ] **Step 4: verifica test verdi**
|
||
|
||
Run: `npm test` — Expected: PASS.
|
||
|
||
- [ ] **Step 5: mailer.ts e api/contact.ts**
|
||
|
||
`src/lib/mailer.ts`:
|
||
```ts
|
||
import nodemailer from 'nodemailer';
|
||
import { getEnv } from './env';
|
||
import type { ContactData } from './contact';
|
||
|
||
export async function sendContactEmail(data: ContactData): Promise<void> {
|
||
const transporter = nodemailer.createTransport({
|
||
host: getEnv('SMTP_HOST'),
|
||
port: Number(getEnv('SMTP_PORT', '587')),
|
||
secure: Number(getEnv('SMTP_PORT', '587')) === 465,
|
||
auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
|
||
});
|
||
await transporter.sendMail({
|
||
from: getEnv('CONTACT_FROM'),
|
||
to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
|
||
replyTo: data.email,
|
||
subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
|
||
text: [
|
||
`Nome: ${data.firstName} ${data.lastName}`.trim(),
|
||
data.phone && `Telefono: ${data.phone}`,
|
||
`E-mail: ${data.email}`,
|
||
'',
|
||
data.message,
|
||
].filter(Boolean).join('\n'),
|
||
});
|
||
}
|
||
```
|
||
|
||
`src/pages/api/contact.ts`:
|
||
```ts
|
||
import type { APIRoute } from 'astro';
|
||
import { validateContact } from '../../lib/contact';
|
||
import { sendContactEmail } from '../../lib/mailer';
|
||
import { rateLimit } from '../../lib/rate-limit';
|
||
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||
if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
|
||
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
|
||
}
|
||
let data: unknown;
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
const result = validateContact(data);
|
||
if (!result.ok) return json(400, { error: result.error });
|
||
try {
|
||
await sendContactEmail(result.value);
|
||
} catch (err) {
|
||
console.error('Invio email fallito:', err);
|
||
return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' });
|
||
}
|
||
return json(200, { ok: true });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: contact.astro**
|
||
|
||
Layout da Contatti.pdf: mappa a sinistra (click-to-load), form a destra, sotto indirizzo/telefono/email + invito.
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
import PageHero from '../components/PageHero.astro';
|
||
import ContactForm from '../components/ContactForm.astro';
|
||
import { site } from '../data/site';
|
||
export const prerender = true;
|
||
const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`);
|
||
---
|
||
<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.">
|
||
<PageHero title="Contattaci" />
|
||
<section class="section">
|
||
<div class="container cgrid">
|
||
<div class="cmap" id="map-box">
|
||
<button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button>
|
||
<p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p>
|
||
</div>
|
||
<ContactForm variant="full" />
|
||
</div>
|
||
<div class="container cinfo">
|
||
<div>
|
||
<h3>Indirizzo</h3>
|
||
<p>{site.address} {site.cityLine}</p>
|
||
</div>
|
||
<div>
|
||
<h3>Telefono</h3>
|
||
<p>{site.phone}</p>
|
||
</div>
|
||
<div>
|
||
<h3>E-mail</h3>
|
||
<p><a href={`mailto:${site.email}`}>{site.email}</a></p>
|
||
</div>
|
||
</div>
|
||
<div class="container">
|
||
<p class="cinvite">Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.</p>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
|
||
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
|
||
.cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
|
||
.cmap__note { font-size: .8rem; color: var(--c-text-light); }
|
||
.cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
|
||
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
|
||
.cinvite { max-width: 640px; }
|
||
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
|
||
</style>
|
||
|
||
<script define:vars={{ mapsQuery }}>
|
||
document.getElementById('map-load')?.addEventListener('click', () => {
|
||
const box = document.getElementById('map-box');
|
||
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
|
||
});
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 7: verifica manuale API**
|
||
|
||
Run (con dev server attivo):
|
||
```bash
|
||
curl -s -X POST localhost:4321/api/contact -H 'Content-Type: application/json' -d '{"firstName":"Test","email":"t@t.it","message":"ciao"}'
|
||
```
|
||
Expected: `{"error":"Invio non riuscito..."}` (502, SMTP finto in `.env`) — la catena valida→invia funziona. Con honeypot: `-d '{"firstName":"T","email":"t@t.it","message":"x","website":"spam"}'` → 400.
|
||
|
||
- [ ] **Step 8: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: pagina contatti e api email con validazione testata"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Blog pubblico (`/blog`, `/blog/[slug]`)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/blog/index.astro`, `src/pages/blog/[slug].astro`, `src/lib/blog-const.ts`, `src/components/blog/Sidebar.astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getDb`, `listPublished`, `getPublishedBySlug`, `listRecent`, `listArchiveMonths` (Task 5), `<PageHero />`.
|
||
- Produces: `CATEGORIES: string[]` in `blog-const.ts` (usato anche dall'admin, Task 15).
|
||
|
||
- [ ] **Step 1: blog-const.ts**
|
||
|
||
```ts
|
||
export const CATEGORIES = [
|
||
'Articoli tecnici & scientifici',
|
||
'Contenuti educativi',
|
||
'LinkedIn / Insight',
|
||
'Eventi & Presidi',
|
||
] as const;
|
||
```
|
||
|
||
- [ ] **Step 2: blog/index.astro (SSR)**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../../layouts/Base.astro';
|
||
import PageHero from '../../components/PageHero.astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { listPublished } from '../../lib/posts';
|
||
import { CATEGORIES } from '../../lib/blog-const';
|
||
export const prerender = false;
|
||
|
||
const PER_PAGE = 9;
|
||
const url = Astro.url;
|
||
const category = url.searchParams.get('categoria') ?? undefined;
|
||
const page = Math.max(1, Number(url.searchParams.get('pagina') ?? '1') || 1);
|
||
const { items, total } = listPublished(getDb(), {
|
||
category: category && (CATEGORIES as readonly string[]).includes(category) ? category : undefined,
|
||
page, perPage: PER_PAGE,
|
||
});
|
||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||
const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
|
||
const linkFor = (cat?: string, p = 1) => {
|
||
const q = new URLSearchParams();
|
||
if (cat) q.set('categoria', cat);
|
||
if (p > 1) q.set('pagina', String(p));
|
||
const s = q.toString();
|
||
return `/blog${s ? `?${s}` : ''}`;
|
||
};
|
||
---
|
||
<Base title="Blog & Edugo" description="Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute.">
|
||
<PageHero title="Blog & Edugo" />
|
||
<section class="section">
|
||
<div class="container">
|
||
<div class="section-heading">
|
||
<h2>Blog & Edugo</h2>
|
||
<p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
|
||
</div>
|
||
<nav class="bfilters" aria-label="categorie">
|
||
<a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>Mostra tutto</a>
|
||
{CATEGORIES.map((c) => (
|
||
<a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>{c}</a>
|
||
))}
|
||
</nav>
|
||
{items.length === 0 ? (
|
||
<p style="text-align:center">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
|
||
) : (
|
||
<div class="bgrid">
|
||
{items.map((post) => (
|
||
<article class="bcard">
|
||
{post.cover && <a href={`/blog/${post.slug}`}><img src={post.cover} alt="" loading="lazy" /></a>}
|
||
<p class="bcard__date">{fmt(post.published_at)}</p>
|
||
<h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
|
||
<p>{post.excerpt}</p>
|
||
<a class="bcard__more" href={`/blog/${post.slug}`}>Per saperne di più →</a>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
{pages > 1 && (
|
||
<nav class="bpages" aria-label="paginazione">
|
||
{Array.from({ length: pages }, (_, i) => (
|
||
<a class:list={['bpages__n', { 'is-active': page === i + 1 }]} href={linkFor(category, i + 1)}>{i + 1}</a>
|
||
))}
|
||
</nav>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
|
||
.bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
|
||
.bfilters__item.is-active { background: var(--c-accent); color: #fff; }
|
||
.bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
|
||
.bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
|
||
.bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
|
||
.bcard h3 a { text-decoration: none; }
|
||
.bcard__more { font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||
.bpages { display: flex; gap: 8px; justify-content: center; margin-top: 50px; }
|
||
.bpages__n { font-family: var(--font-heading); text-decoration: none; padding: 8px 14px; color: var(--c-text); }
|
||
.bpages__n.is-active { background: var(--c-dark); color: #fff; }
|
||
@media (max-width: 991px) { .bgrid { grid-template-columns: 1fr 1fr; } }
|
||
@media (max-width: 600px) { .bgrid { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: Sidebar.astro**
|
||
|
||
```astro
|
||
---
|
||
import { getDb } from '../../lib/db';
|
||
import { listRecent, listArchiveMonths } from '../../lib/posts';
|
||
import { CATEGORIES } from '../../lib/blog-const';
|
||
const db = getDb();
|
||
const recent = listRecent(db, 4);
|
||
const months = listArchiveMonths(db);
|
||
const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`));
|
||
---
|
||
<aside class="bside">
|
||
<h3>Categorie</h3>
|
||
{CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
|
||
<h3>Ultimi articoli</h3>
|
||
{recent.map((p) => (
|
||
<p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
|
||
))}
|
||
<h3>Archivio</h3>
|
||
{months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
|
||
</aside>
|
||
|
||
<style>
|
||
.bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
|
||
.bside a { text-decoration: none; color: var(--c-text); }
|
||
.bside a:hover { color: var(--c-accent-dark); }
|
||
.bside__month { text-transform: capitalize; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: blog/[slug].astro (SSR)**
|
||
|
||
```astro
|
||
---
|
||
import Base from '../../layouts/Base.astro';
|
||
import Sidebar from '../../components/blog/Sidebar.astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { getPublishedBySlug } from '../../lib/posts';
|
||
export const prerender = false;
|
||
|
||
const post = getPublishedBySlug(getDb(), Astro.params.slug!);
|
||
if (!post) return Astro.redirect('/404');
|
||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
|
||
---
|
||
<Base title={post.title} description={post.excerpt}>
|
||
<section class="section">
|
||
<div class="container art">
|
||
<article>
|
||
{post.cover && <img class="art__cover" src={post.cover} alt="" />}
|
||
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p>
|
||
<h1>{post.title}</h1>
|
||
<div class="art__body" set:html={post.body_html} />
|
||
</article>
|
||
<Sidebar />
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.art { display: grid; grid-template-columns: 2.4fr 1fr; gap: 60px; }
|
||
.art__cover { width: 100%; margin-bottom: 24px; }
|
||
.art__meta { font-size: .8rem; color: var(--c-text-light); }
|
||
.art__body :global(img) { margin-block: 20px; }
|
||
.art__body :global(blockquote) { border-left: 3px solid var(--c-accent); margin: 24px 0; padding: 6px 0 6px 20px; font-style: italic; color: var(--c-heading); }
|
||
@media (max-width: 991px) { .art { grid-template-columns: 1fr; } }
|
||
</style>
|
||
```
|
||
Nota: `body_html` è già sanitizzato al salvataggio (Task 15); `set:html` è accettabile solo per questo campo.
|
||
|
||
- [ ] **Step 5: verifica con dati di prova**
|
||
|
||
```bash
|
||
sqlite3 data/insanitylab.db "INSERT INTO posts (title, slug, category, excerpt, body_html, draft, published_at) VALUES ('Articolo di prova', 'articolo-di-prova', 'Contenuti educativi', 'Un estratto di prova.', '<p>Corpo <strong>di prova</strong>.</p>', 0, datetime('now'));"
|
||
curl -s localhost:4321/blog | grep -o 'Articolo di prova' | head -1
|
||
curl -s localhost:4321/blog/articolo-di-prova | grep -o 'Corpo' | head -1
|
||
curl -s -o /dev/null -w '%{http_code}' localhost:4321/blog/inesistente
|
||
```
|
||
Expected: `Articolo di prova`, `Corpo`, redirect/404 per lo slug inesistente. Verifica filtri categoria e paginazione con più insert se serve.
|
||
|
||
- [ ] **Step 6: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: blog pubblico con filtri, paginazione e articolo"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Area admin — login, lista, editor TipTap, API CRUD e upload (TDD su sanitizzazione)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/sanitize.ts`, `src/lib/post-body.ts`, `src/layouts/Admin.astro`, `src/pages/admin/login.astro`, `src/pages/admin/logout.ts`, `src/pages/admin/index.astro`, `src/pages/admin/new.astro`, `src/pages/admin/edit/[id].astro`, `src/components/admin/PostForm.astro`, `src/pages/api/admin/posts/index.ts`, `src/pages/api/admin/posts/[id].ts`, `src/pages/api/admin/upload.ts`, `src/pages/uploads/[...path].ts`
|
||
- Test: `tests/sanitize.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: auth/middleware (Task 6), posts (Task 5), `slugify` (Task 4), `CATEGORIES` (Task 14), `rateLimit` (Task 4), `getEnv` (Task 4).
|
||
- Produces:
|
||
- `sanitizeHtml(html: string): string` in `src/lib/sanitize.ts`.
|
||
- `POST /api/admin/posts` body `{ title, slug?, category, excerpt, body_html, cover?, draft }` → 201 `{ id, slug }`.
|
||
- `PUT /api/admin/posts/[id]` stesso body → 200; `DELETE /api/admin/posts/[id]` → 200.
|
||
- `POST /api/admin/upload` (multipart, campo `file`) → 200 `{ url: '/uploads/<nome>' }`.
|
||
- `GET /uploads/<file>` serve da `UPLOADS_DIR`.
|
||
|
||
- [ ] **Step 1: test sanitizzazione (falliranno)**
|
||
|
||
`tests/sanitize.test.ts`:
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { sanitizeHtml } from '../src/lib/sanitize';
|
||
|
||
describe('sanitizeHtml', () => {
|
||
it('mantiene la formattazione consentita', () => {
|
||
const html = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
|
||
expect(sanitizeHtml(html)).toBe(html);
|
||
});
|
||
it('rimuove script e handler', () => {
|
||
expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
|
||
});
|
||
it('consente immagini locali e https, blocca javascript:', () => {
|
||
expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
|
||
expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: verifica che falliscano, poi implementa sanitize.ts**
|
||
|
||
Run: `npx vitest run tests/sanitize.test.ts` — FAIL. Poi:
|
||
|
||
`src/lib/sanitize.ts`:
|
||
```ts
|
||
import sanitize from 'sanitize-html';
|
||
|
||
export function sanitizeHtml(html: string): string {
|
||
return sanitize(html, {
|
||
allowedTags: ['h2', 'h3', 'p', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'br', 'hr'],
|
||
allowedAttributes: { a: ['href', 'rel', 'target'], img: ['src', 'alt'] },
|
||
allowedSchemes: ['https', 'http', 'mailto'],
|
||
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
||
allowProtocolRelative: false,
|
||
});
|
||
}
|
||
```
|
||
|
||
Run: `npm test` — PASS. (Se `sanitize-html` mantiene `src` relativi di default lo verifichi col test; in caso contrario aggiungi `allowedSchemesByTag` o normalizza i percorsi.)
|
||
|
||
- [ ] **Step 3: layout Admin e login**
|
||
|
||
`src/layouts/Admin.astro`:
|
||
```astro
|
||
---
|
||
interface Props { title: string }
|
||
const { title } = Astro.props;
|
||
const user = Astro.locals.user;
|
||
---
|
||
<!doctype html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>{title} — Admin InsanityLab</title>
|
||
<meta name="robots" content="noindex" />
|
||
<style is:global>
|
||
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
|
||
.abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||
.abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
|
||
.awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
|
||
.abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
|
||
.abtn--danger { background: #a33; }
|
||
.afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
|
||
table { width: 100%; border-collapse: collapse; background: #fff; }
|
||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
|
||
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
|
||
.pill--draft { background: #f3d9a4; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="abar">
|
||
<strong>InsanityLab · Admin</strong>
|
||
<span>
|
||
{user && <>Ciao, {user.username} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
|
||
</span>
|
||
</div>
|
||
<div class="awrap"><slot /></div>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
`src/pages/admin/login.astro` (POST gestito nel frontmatter):
|
||
```astro
|
||
---
|
||
import Admin from '../../layouts/Admin.astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { login, SESSION_COOKIE } from '../../lib/auth';
|
||
import { rateLimit } from '../../lib/rate-limit';
|
||
export const prerender = false;
|
||
|
||
let error = '';
|
||
if (Astro.request.method === 'POST') {
|
||
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
|
||
error = 'Troppi tentativi: riprova tra 15 minuti.';
|
||
} else {
|
||
const form = await Astro.request.formData();
|
||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||
if (token) {
|
||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||
httpOnly: true, sameSite: 'lax', path: '/',
|
||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||
});
|
||
return Astro.redirect('/admin');
|
||
}
|
||
error = 'Credenziali non valide.';
|
||
}
|
||
}
|
||
---
|
||
<Admin title="Login">
|
||
<h1>Accesso</h1>
|
||
{error && <p style="color:#a33">{error}</p>}
|
||
<form method="post" style="max-width:360px">
|
||
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
|
||
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||
<button class="abtn" type="submit">Entra</button>
|
||
</form>
|
||
</Admin>
|
||
```
|
||
|
||
`src/pages/admin/logout.ts`:
|
||
```ts
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { logout, SESSION_COOKIE } from '../../lib/auth';
|
||
export const prerender = false;
|
||
|
||
export const GET: APIRoute = ({ cookies, redirect }) => {
|
||
const token = cookies.get(SESSION_COOKIE)?.value;
|
||
if (token) logout(getDb(), token);
|
||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||
return redirect('/admin/login');
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: lista articoli**
|
||
|
||
`src/pages/admin/index.astro`:
|
||
```astro
|
||
---
|
||
import Admin from '../../layouts/Admin.astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { listAllPosts } from '../../lib/posts';
|
||
export const prerender = false;
|
||
const posts = listAllPosts(getDb());
|
||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||
---
|
||
<Admin title="Articoli">
|
||
<h1>Articoli</h1>
|
||
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
|
||
<table>
|
||
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
|
||
<tbody>
|
||
{posts.map((p) => (
|
||
<tr>
|
||
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
|
||
<td>{p.category}</td>
|
||
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
|
||
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
|
||
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
|
||
</Admin>
|
||
|
||
<script>
|
||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||
b.addEventListener('click', async () => {
|
||
if (!confirm('Eliminare definitivamente questo articolo?')) return;
|
||
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
||
if (res.ok) location.reload();
|
||
else alert('Eliminazione non riuscita.');
|
||
}));
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 5: PostForm.astro con TipTap**
|
||
|
||
```astro
|
||
---
|
||
import { CATEGORIES } from '../../lib/blog-const';
|
||
import type { Post } from '../../lib/posts';
|
||
interface Props { post?: Post }
|
||
const { post } = Astro.props;
|
||
---
|
||
<form id="post-form" data-id={post?.id ?? ''}>
|
||
<input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
|
||
<input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
|
||
<select class="afield" name="category">
|
||
{CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
|
||
</select>
|
||
<textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
|
||
|
||
<label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
|
||
<input type="hidden" name="cover" value={post?.cover ?? ''} />
|
||
<p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
|
||
|
||
<div class="ed-toolbar" id="ed-toolbar">
|
||
<button type="button" data-cmd="h2">H2</button>
|
||
<button type="button" data-cmd="h3">H3</button>
|
||
<button type="button" data-cmd="bold"><b>B</b></button>
|
||
<button type="button" data-cmd="italic"><i>I</i></button>
|
||
<button type="button" data-cmd="bullet">• Elenco</button>
|
||
<button type="button" data-cmd="ordered">1. Elenco</button>
|
||
<button type="button" data-cmd="quote">“ Citazione</button>
|
||
<button type="button" data-cmd="link">Link</button>
|
||
<button type="button" data-cmd="image">Immagine</button>
|
||
</div>
|
||
<div id="editor" class="ed-body"></div>
|
||
|
||
<label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
|
||
<p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
|
||
</form>
|
||
|
||
<style>
|
||
.ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
|
||
.ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
|
||
.ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
|
||
.ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
|
||
.ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
|
||
</style>
|
||
|
||
<script>
|
||
import { Editor } from '@tiptap/core';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import Image from '@tiptap/extension-image';
|
||
import Link from '@tiptap/extension-link';
|
||
|
||
const form = document.getElementById('post-form') as HTMLFormElement;
|
||
const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
|
||
|
||
const editor = new Editor({
|
||
element: document.getElementById('editor')!,
|
||
extensions: [
|
||
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||
Image,
|
||
Link.configure({ openOnClick: false }),
|
||
],
|
||
content: initial,
|
||
});
|
||
|
||
async function uploadFile(file: File): Promise<string | null> {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
|
||
if (!res.ok) { alert('Upload non riuscito.'); return null; }
|
||
return (await res.json()).url;
|
||
}
|
||
|
||
document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
|
||
const btn = (e.target as HTMLElement).closest('button');
|
||
if (!btn) return;
|
||
const c = editor.chain().focus();
|
||
switch (btn.dataset.cmd) {
|
||
case 'h2': c.toggleHeading({ level: 2 }).run(); break;
|
||
case 'h3': c.toggleHeading({ level: 3 }).run(); break;
|
||
case 'bold': c.toggleBold().run(); break;
|
||
case 'italic': c.toggleItalic().run(); break;
|
||
case 'bullet': c.toggleBulletList().run(); break;
|
||
case 'ordered': c.toggleOrderedList().run(); break;
|
||
case 'quote': c.toggleBlockquote().run(); break;
|
||
case 'link': {
|
||
const url = prompt('URL del link:');
|
||
if (url) c.setLink({ href: url }).run();
|
||
break;
|
||
}
|
||
case 'image': {
|
||
const input = document.createElement('input');
|
||
input.type = 'file'; input.accept = 'image/*';
|
||
input.onchange = async () => {
|
||
const url = input.files?.[0] && await uploadFile(input.files[0]);
|
||
if (url) editor.chain().focus().setImage({ src: url }).run();
|
||
};
|
||
input.click();
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('cover-file')!.addEventListener('change', async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
const url = await uploadFile(file);
|
||
if (url) {
|
||
(form.elements.namedItem('cover') as HTMLInputElement).value = url;
|
||
document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
|
||
}
|
||
});
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const id = form.dataset.id;
|
||
const body = {
|
||
title: (form.elements.namedItem('title') as HTMLInputElement).value,
|
||
slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
|
||
category: (form.elements.namedItem('category') as HTMLSelectElement).value,
|
||
excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
|
||
cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
|
||
draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
|
||
body_html: editor.getHTML(),
|
||
};
|
||
const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
|
||
method: id ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const msg = document.getElementById('save-msg')!;
|
||
if (res.ok) {
|
||
msg.textContent = 'Salvato ✓';
|
||
if (!id) location.href = '/admin';
|
||
} else {
|
||
msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
|
||
}
|
||
});
|
||
</script>
|
||
```
|
||
|
||
`src/pages/admin/new.astro`:
|
||
```astro
|
||
---
|
||
import Admin from '../../layouts/Admin.astro';
|
||
import PostForm from '../../components/admin/PostForm.astro';
|
||
export const prerender = false;
|
||
---
|
||
<Admin title="Nuovo articolo">
|
||
<h1>Nuovo articolo</h1>
|
||
<script id="initial-body" type="text/plain"></script>
|
||
<PostForm />
|
||
</Admin>
|
||
```
|
||
|
||
`src/pages/admin/edit/[id].astro`:
|
||
```astro
|
||
---
|
||
import Admin from '../../../layouts/Admin.astro';
|
||
import PostForm from '../../../components/admin/PostForm.astro';
|
||
import { getDb } from '../../../lib/db';
|
||
import { getPostById } from '../../../lib/posts';
|
||
export const prerender = false;
|
||
const post = getPostById(getDb(), Number(Astro.params.id));
|
||
if (!post) return Astro.redirect('/admin');
|
||
---
|
||
<Admin title={`Modifica: ${post.title}`}>
|
||
<h1>Modifica articolo</h1>
|
||
<script id="initial-body" type="text/plain" set:html={post.body_html}></script>
|
||
<PostForm post={post} />
|
||
</Admin>
|
||
```
|
||
|
||
- [ ] **Step 6: API CRUD**
|
||
|
||
`src/lib/post-body.ts` (parsing condiviso tra POST e PUT):
|
||
```ts
|
||
import { slugify } from './slug';
|
||
import { sanitizeHtml } from './sanitize';
|
||
import { CATEGORIES } from './blog-const';
|
||
|
||
export function parsePostBody(data: Record<string, unknown>) {
|
||
const title = String(data.title ?? '').trim();
|
||
if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
|
||
const category = String(data.category ?? '');
|
||
if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
|
||
const slug = slugify(String(data.slug ?? '') || title);
|
||
if (!slug) return { error: 'Slug non valido.' } as const;
|
||
return {
|
||
value: {
|
||
title, slug, category,
|
||
excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
|
||
body_html: sanitizeHtml(String(data.body_html ?? '')),
|
||
cover: data.cover ? String(data.cover) : null,
|
||
draft: Boolean(data.draft),
|
||
},
|
||
} as const;
|
||
}
|
||
```
|
||
|
||
`src/pages/api/admin/posts/index.ts`:
|
||
```ts
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../lib/db';
|
||
import { createPost } from '../../../../lib/posts';
|
||
import { parsePostBody } from '../../../../lib/post-body';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const POST: APIRoute = async ({ request }) => {
|
||
let data: Record<string, unknown>;
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
const parsed = parsePostBody(data);
|
||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||
try {
|
||
const id = createPost(getDb(), parsed.value);
|
||
return json(201, { id, slug: parsed.value.slug });
|
||
} catch (err: any) {
|
||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||
throw err;
|
||
}
|
||
};
|
||
```
|
||
|
||
`src/pages/api/admin/posts/[id].ts`:
|
||
```ts
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../lib/db';
|
||
import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
|
||
import { parsePostBody } from '../../../../lib/post-body';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const PUT: APIRoute = async ({ params, request }) => {
|
||
const id = Number(params.id);
|
||
if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
|
||
let data: Record<string, unknown>;
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
const parsed = parsePostBody(data);
|
||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||
try {
|
||
updatePost(getDb(), id, parsed.value);
|
||
return json(200, { ok: true });
|
||
} catch (err: any) {
|
||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
export const DELETE: APIRoute = ({ params }) => {
|
||
deletePost(getDb(), Number(params.id));
|
||
return json(200, { ok: true });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 7: upload e route file**
|
||
|
||
`src/pages/api/admin/upload.ts`:
|
||
```ts
|
||
import type { APIRoute } from 'astro';
|
||
import { writeFile, mkdir } from 'node:fs/promises';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { join, extname } from 'node:path';
|
||
import { getEnv } from '../../../lib/env';
|
||
export const prerender = false;
|
||
|
||
const ALLOWED: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
|
||
const MAX_BYTES = 5 * 1024 * 1024;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const POST: APIRoute = async ({ request }) => {
|
||
const form = await request.formData().catch(() => null);
|
||
const file = form?.get('file');
|
||
if (!(file instanceof File)) return json(400, { error: 'Nessun file.' });
|
||
const ext = extname(file.name).toLowerCase();
|
||
if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
|
||
if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
|
||
const dir = getEnv('UPLOADS_DIR', 'uploads');
|
||
await mkdir(dir, { recursive: true });
|
||
const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
||
await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
|
||
return json(200, { url: `/uploads/${name}` });
|
||
};
|
||
```
|
||
|
||
`src/pages/uploads/[...path].ts`:
|
||
```ts
|
||
import type { APIRoute } from 'astro';
|
||
import { readFile } from 'node:fs/promises';
|
||
import { join, normalize, extname } from 'node:path';
|
||
import { getEnv } from '../../lib/env';
|
||
export const prerender = false;
|
||
|
||
const MIME: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
|
||
|
||
export const GET: APIRoute = async ({ params }) => {
|
||
const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
|
||
if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
|
||
const mime = MIME[extname(rel).toLowerCase()];
|
||
if (!mime) return new Response('Not found', { status: 404 });
|
||
try {
|
||
const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
|
||
return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
|
||
} catch {
|
||
return new Response('Not found', { status: 404 });
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 8: verifica end-to-end manuale**
|
||
|
||
Con dev server attivo e utente creato (`npm run create-user -- admin prova1234`):
|
||
1. `curl -s -o /dev/null -w '%{http_code}' localhost:4321/admin` → `302` (redirect a login da non autenticati).
|
||
2. `curl -s -X POST localhost:4321/api/admin/posts -H 'Content-Type: application/json' -d '{}'` → `401`.
|
||
3. Browser: login su `/admin/login`, crea articolo con immagine nel corpo, salva come pubblicato, verifica su `/blog` e `/blog/<slug>`; modifica; elimina (con conferma).
|
||
Expected: flusso completo senza errori console.
|
||
|
||
- [ ] **Step 9: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat: area admin con editor tiptap, api crud e upload immagini"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: SEO, favicon, robots e smoke test finale
|
||
|
||
**Files:**
|
||
- Create: `public/robots.txt`, `public/favicon.svg`, `scripts/smoke.sh`
|
||
|
||
**Interfaces:**
|
||
- Consumes: tutto il sito.
|
||
|
||
- [ ] **Step 1: robots e favicon**
|
||
|
||
`public/robots.txt`:
|
||
```
|
||
User-agent: *
|
||
Disallow: /admin
|
||
Disallow: /api
|
||
Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml
|
||
```
|
||
|
||
`public/favicon.svg` — battito/onda del logo InsanityLab su fondo scuro (ricava la forma dal logo estratto; in mancanza):
|
||
```svg
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||
<rect width="32" height="32" rx="6" fill="#3a2d26"/>
|
||
<path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b5a48b" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
```
|
||
|
||
- [ ] **Step 2: smoke test**
|
||
|
||
`scripts/smoke.sh`:
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
cd "$(dirname "$0")/.."
|
||
npm run build
|
||
PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
|
||
trap 'kill $SRV' EXIT
|
||
sleep 2
|
||
fail=0
|
||
for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
|
||
echo "$code $path"
|
||
[ "$code" = "200" ] || fail=1
|
||
done
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
|
||
echo "$code /admin (atteso 302)"
|
||
[ "$code" = "302" ] || fail=1
|
||
exit $fail
|
||
```
|
||
|
||
Run: `chmod +x scripts/smoke.sh && ./scripts/smoke.sh`
|
||
Expected: tutti `200`, `/admin` `302`, exit 0. (Il server standalone usa `PORT`; se la porta è occupata cambiala.)
|
||
|
||
- [ ] **Step 3: controllo qualità finale**
|
||
|
||
- `npm test` → tutti verdi.
|
||
- `npx astro check` → nessun errore.
|
||
- Lighthouse rapido (facoltativo): `npx lighthouse http://localhost:4399/ --quiet --chrome-flags='--headless'` — performance e SEO ≥ 90 attesi su pagine statiche.
|
||
|
||
- [ ] **Step 4: commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "chore: robots, favicon e smoke test"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Remote GitLab e push
|
||
|
||
**Files:**
|
||
- Nessun file di progetto; solo configurazione git.
|
||
|
||
- [ ] **Step 1: verifica accesso GitLab**
|
||
|
||
Run: `command -v glab && glab auth status`
|
||
- Se `glab` autenticato: `glab repo create insanitylab-website --private --source . --remote origin --push`.
|
||
- Se `glab` assente o non autenticato: chiedi all'operatore l'URL del repo GitLab (o le credenziali/namespace) e usa `git remote add origin <url> && git push -u origin main`.
|
||
|
||
- [ ] **Step 2: verifica push**
|
||
|
||
Run: `git remote -v && git log origin/main --oneline | head -3`
|
||
Expected: remote configurato, commit presenti sul remote.
|
||
|
||
---
|
||
|
||
## Ordine di esecuzione e note
|
||
|
||
1 → 2 → 3 → 4 → 5 → 6 → 8 → 7 → 9 → 10 → 11 → 12 → 13 → 14 → 15 → 16 → 17
|
||
(Il Task 8 precede il 7 perché Header/Footer consumano `site.ts`; i task 9–15 dipendono da 7+8.)
|
||
|
||
**Punti aperti da segnalare all'operatore a fine esecuzione:**
|
||
- Testi Storylab 2025/2026/OGGI mancanti (placeholder «Contenuto in arrivo»).
|
||
- Orario agenda = placeholder dal template (nomi trainer finti).
|
||
- URL social reali da confermare in `src/data/site.ts`.
|
||
- Credenziali SMTP reali da mettere in `.env` (ora finte).
|
||
- Testi estesi/FAQ dei programmi diversi da Performance Class: bozze ragionevoli da far validare al cliente.
|
||
|
||
|
||
|
||
|
||
|