# Review package: 063972d..HEAD ## Commits 29d633c chore: robots, favicon e smoke test f0ad162 feat: area admin con editor tiptap, api crud e upload immagini f3fb2fe feat: blog pubblico con filtri, paginazione e articolo e984010 feat: pagina contatti e api email con validazione testata be4d933 feat: pagine programmi con dettaglio e pricing performance class ed35491 feat: pagina training c4cb05e fix: immagine sezione about a risoluzione adeguata ba89568 feat: pagina about 45e5214 feat: homepage completa da design Figma 2fdd1b9 feat: layout base con header, footer e 404 ac75720 fix: apostrofi tipografici nei dati statici 50ce224 feat: dati statici contenuti da design Figma 6caba6f feat: autenticazione a sessioni, middleware admin e script create-user d3b5e67 feat: layer sqlite e repository posts con test 42d603b feat: slugify, rate limit ed env helper con test c9a9ff9 feat: design tokens e css globale 4445c42 fix: loghi insanitylab estratti dal pdf figma al posto dei loghi template 810e5d8 fix: limite altezza in optimize-assets, pesi immagini e report dimensioni 551957e fix: pipeline asset riproducibile con script di ottimizzazione d8ef59e feat: asset immagini estratti da Figma e mappati 5f7ed1f chore: scaffold progetto Astro 5 con adapter node 9780e51 Aggiunge piano implementazione sito ## Files changed .env.example | 8 + .gitignore | 8 + .superpowers/sdd/task-2-report.md | 247 + README.md | 137 + astro.config.mjs | 10 + data/.gitkeep | 0 .../plans/2026-07-01-insanitylab-website.md | 3513 ++++++++++ package-lock.json | 6875 ++++++++++++++++++++ package.json | 38 + public/favicon.svg | 4 + public/img/logo-dark.png | Bin 0 -> 26648 bytes public/img/logo-light.png | Bin 0 -> 36949 bytes public/robots.txt | 4 + scripts/apply-asset-map.mjs | 42 + scripts/asset-map.json | 37 + scripts/create-user.mjs | 23 + scripts/extract-fig-images.sh | 17 + scripts/extract-logos.sh | 53 + scripts/optimize-assets.sh | 40 + scripts/smoke.sh | 17 + src/assets/img/about-exterior.jpg | Bin 0 -> 190714 bytes src/assets/img/about-team-group.jpg | Bin 0 -> 25553 bytes src/assets/img/about-trainer-1.jpg | Bin 0 -> 148343 bytes src/assets/img/about-trainer-2.jpg | Bin 0 -> 135795 bytes src/assets/img/about-trainer-3.jpg | Bin 0 -> 118975 bytes src/assets/img/about-trainer-4.jpg | Bin 0 -> 162617 bytes src/assets/img/about-training-group.jpg | Bin 0 -> 143401 bytes src/assets/img/feature-balance.jpg | Bin 0 -> 101290 bytes src/assets/img/feature-longevity.jpg | Bin 0 -> 25258 bytes src/assets/img/feature-performance.jpg | Bin 0 -> 79834 bytes src/assets/img/hero-1.jpg | Bin 0 -> 97196 bytes src/assets/img/info-balance.jpg | Bin 0 -> 136394 bytes src/assets/img/info-longevity.jpg | Bin 0 -> 252166 bytes src/assets/img/info-performance.jpg | Bin 0 -> 260295 bytes src/assets/img/method-step-1.jpg | Bin 0 -> 105410 bytes src/assets/img/method-step-2.jpg | Bin 0 -> 175977 bytes src/assets/img/method-step-3.jpg | Bin 0 -> 11290 bytes src/assets/img/method-step-4.jpg | Bin 0 -> 30346 bytes src/assets/img/partner-aon.png | Bin 0 -> 8049 bytes src/assets/img/partner-edenred.png | Bin 0 -> 6875 bytes src/assets/img/partner-howden.png | Bin 0 -> 3756 bytes src/assets/img/partner-welbee.png | Bin 0 -> 12423 bytes src/assets/img/quote-bg.jpg | Bin 0 -> 469103 bytes src/assets/img/team-antonello.jpg | Bin 0 -> 149301 bytes src/assets/img/team-donata.jpg | Bin 0 -> 138907 bytes src/assets/img/team-eleonora.jpg | Bin 0 -> 145996 bytes src/assets/img/team-nicola.jpg | Bin 0 -> 171336 bytes src/assets/img/training-fit-remote.jpg | Bin 0 -> 38375 bytes src/assets/img/training-one-to-one.jpg | Bin 0 -> 38837 bytes src/assets/img/training-performance-class.jpg | Bin 0 -> 242256 bytes src/assets/img/training-small-groups.jpg | Bin 0 -> 142807 bytes src/components/Agenda.astro | 54 + src/components/ContactForm.astro | 42 + src/components/Footer.astro | 49 + src/components/Header.astro | 45 + src/components/PageHero.astro | 15 + src/components/about/Timeline.astro | 34 + src/components/admin/PostForm.astro | 136 + src/components/blog/Sidebar.astro | 26 + src/components/home/FeatureBlocks.astro | 35 + src/components/home/HeroSlider.astro | 54 + src/components/home/InfoColumns.astro | 26 + src/components/home/MethodSteps.astro | 39 + src/components/home/PartnersStrip.astro | 18 + src/components/home/ProgramsGrid.astro | 38 + src/components/home/QuoteBanner.astro | 22 + src/components/home/TeamSection.astro | 34 + src/components/home/TrainingCards.astro | 42 + src/data/agenda.ts | 36 + src/data/method.ts | 9 + src/data/partners.ts | 6 + src/data/pricing.ts | 17 + src/data/programs.ts | 104 + src/data/site.ts | 34 + src/data/team.ts | 24 + src/data/trainings.ts | 6 + src/env.d.ts | 7 + src/layouts/Admin.astro | 36 + src/layouts/Base.astro | 33 + src/lib/auth.ts | 48 + src/lib/blog-const.ts | 6 + src/lib/contact.ts | 31 + src/lib/db.ts | 47 + src/lib/env.ts | 5 + src/lib/mailer.ts | 25 + src/lib/post-body.ts | 21 + src/lib/posts.ts | 74 + src/lib/rate-limit.ts | 17 + src/lib/sanitize.ts | 11 + src/lib/slug.ts | 8 + src/middleware.ts | 24 + src/pages/404.astro | 14 + src/pages/about.astro | 166 + src/pages/admin/edit/[id].astro | 14 + src/pages/admin/index.astro | 37 + src/pages/admin/login.astro | 34 + src/pages/admin/logout.ts | 11 + src/pages/admin/new.astro | 10 + src/pages/api/admin/posts/[id].ts | 29 + src/pages/api/admin/posts/index.ts | 22 + src/pages/api/admin/upload.ts | 26 + src/pages/api/contact.ts | 26 + src/pages/blog/[slug].astro | 33 + src/pages/blog/index.astro | 81 + src/pages/contact.astro | 55 + src/pages/index.astro | 32 + src/pages/programs/[slug].astro | 98 + src/pages/programs/index.astro | 38 + src/pages/training.astro | 57 + src/pages/uploads/[...path].ts | 20 + src/styles/global.css | 54 + tests/auth.test.ts | 45 + tests/contact.test.ts | 33 + tests/posts.test.ts | 74 + tests/rate-limit.test.ts | 23 + tests/sanitize.test.ts | 16 + tests/slug.test.ts | 20 + tsconfig.json | 5 + uploads/.gitkeep | 0 vitest.config.ts | 5 + 120 files changed, 13489 insertions(+) ## Diff diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7b2033f --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68a080b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.astro/ +.env +data/*.db* +uploads/* +!uploads/.gitkeep +assets-src/ diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 0000000..6318927 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,247 @@ +# Task 2 Report — Estrazione e mappatura asset immagine da Figma + +**Data:** 2026-07-01 +**Fonte:** `../FigmaDoc/Web Insanity.fig` (60 immagini uniche + ~66 duplicati = 126 estratte) +**Destinazione:** `src/assets/img/` (31 file) + `public/img/` (2 loghi) + +--- + +## Mappature completate + +### Hero +| File destinazione | Hash sorgente | Descrizione | +|---|---|---| +| `hero-1.jpg` | `8b71f158...` | Uomo con palla medica (medicine ball) che la tiene verso donna in posizione crunch, palestra scura — "uomo con palla medica + donna" ✓ | + +### Info sezioni (PERFORMANCE / BALANCE / LONGEVITY) +| File destinazione | Hash sorgente | Descrizione | +|---|---|---| +| `info-performance.jpg` | `0f803439...` | Uomo muscoloso in planche su sfondo grigio neutro | +| `info-balance.jpg` | `432d397a...` | Due donne sedute schiena a schiena in abiti sportivi beige/neutri | +| `info-longevity.jpg` | `d7fcf0aa...` | Donna anziana in posizione push-up — longevità attiva | + +### Feature sezioni (immagini più grandi per ciascun pilastro) +| File destinazione | Hash sorgente | Descrizione | +|---|---|---| +| `feature-performance.jpg` | `6d602573...` | Uomo con chalk sulle mani — preparazione sollevamento pesi, performance atletica | +| `feature-balance.jpg` | `16e611de...` | Gruppo yoga all'aperto al tramonto — equilibrio e benessere | +| `feature-longevity.jpg` | `98f1f8d2...` | Donna anziana in piedi con bottiglia d'acqua, outfit sportivo beige — longevità sana | + +### Quote background +| File destinazione | Hash sorgente | Descrizione | +|---|---|---| +| `quote-bg.jpg` | `28e8157d...` | Scritta "PERFORMANCE. BALANCE. LONGEVITY." su muro di intonaco — elemento visivo identitario | + +### Training programs +| File destinazione | Hash sorgente | Descrizione | +|---|---|---| +| `training-one-to-one.jpg` | `85808a0c...` | Trainer e cliente a colloquio in palestra, tablet in mano — consulenza personalizzata | +| `training-small-groups.jpg` | `bb632b8d...` | Due donne con manubri leggeri in posizione atletica — small group training | +| `training-performance-class.jpg` | `91aa82b5...` | Grande gruppo in sala fitness/danza con palle fitball — performance class | +| `training-fit-remote.jpg` | `86c1363b...` | Donna a casa sul tappetino con laptop — allenamento remoto online | + +### Team Trainer (homepage — 4 ritratti luce dorata) +| File destinazione | Hash sorgente | Persona identificata | +|---|---|---| +| `team-donata.jpg` | `0b6cf694...` | Donna capelli lunghi scuri dritti, luce dorada laterale — Donata ✓ | +| `team-nicola.jpg` | `1cc33c41...` | Uomo barba folta, turtleneck scuro, luce dorata — Nicola ✓ | +| `team-eleonora.jpg` | `a75ca105...` | Donna capelli biondi/chiari, stessa luce dorata — Eleonora ✓ | +| `team-antonello.jpg` | `e3c84f99...` | Uomo occhiali tondi, capelli rasatissimi, luce dorata — Antonello ✓ | + +### Il Metodo (4 passi circolari) +| File destinazione | Hash sorgente | Step identificato | +|---|---|---| +| `method-step-1.jpg` | `88cfcd5f...` | Calibro plicometro su addome — valutazione composizione corporea | +| `method-step-2.jpg` | `d21671bb...` | Donna in cucina con verdure fresche e metro — pianificazione nutrizionale | +| `method-step-3.jpg` | `a3e84d7d...` | Silhouette uomo in palestra in controluce — sessione allenamento | +| `method-step-4.jpg` | `e50b6f78...` | Scarpe su tapis roulant — monitoraggio continuo e progressione | + +### Partner loghi +| File destinazione | Hash sorgente | Logo identificato | +|---|---|---| +| `partner-edenred.png` | `bee22f37...` | "Ticket Welfare Edenred" — logo rosso/blu su sfondo trasparente ✓ | +| `partner-welbee.png` | `cf079250...` | "welbee" — logo verde su sfondo bianco ✓ | +| `partner-aon.png` | `014a7708...` | "Aon" — logo rosso su sfondo bianco ✓ | +| `partner-howden.png` | `872c3679...` | "HOWDEN" — logo blu su sfondo bianco ✓ | + +### Loghi InsanityLab (public/img/) +| File destinazione | Sorgente | Note | +|---|---|---| +| `logo-dark.png` | `../Dati/logo-dark.png` | **Fallback** — logo non presente nel file Figma | +| `logo-light.png` | `../Dati/logo-light.png` | **Fallback** — logo non presente nel file Figma | + +### About page (bonus — non richiesti dal brief) +| File destinazione | Hash sorgente | Descrizione | +|---|---|---| +| `about-exterior.jpg` | `8ffc5f7a...` | Facciata esterna palestra/studio InsanityLab, sera | +| `about-team-group.jpg` | `32ecd00a...` | Foto di gruppo team InsanityLab in bianco e nero | +| `about-training-group.jpg` | `d4608b10...` | Gruppo che salta con kettlebell in sala beige | +| `about-trainer-1.jpg` | `dafcc193...` | Trainer donna capelli scuri ondulati, luce dorata | +| `about-trainer-2.jpg` | `9c50f1d3...` | Trainer donna capelli scuri più corti, matura, luce dorata | +| `about-trainer-3.jpg` | `c51a49b9...` | Trainer donna capelli neri lisci, sorriso, sfondo chiaro | +| `about-trainer-4.jpg` | `3341a11b...` | Trainer uomo giovane senza barba, turtleneck scuro | + +--- + +## Immagini attese ma non trovate nel Figma + +| Nome atteso | Motivo mancanza | Azione intrapresa | +|---|---|---| +| `logo-dark.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-dark.png` ✓ | +| `logo-light.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-light.png` ✓ | + +Tutti e 4 i loghi partner sono stati trovati nel Figma. Nessuna altra immagine richiesta dal brief risulta mancante. + +--- + +## Dimensioni finali + +``` +src/assets/img/ 3.7 MB (31 file JPG + 4 PNG partner loghi) +public/img/ ~3.4 KB (2 PNG logo: logo-dark.png 1.7 KB, logo-light.png 1.7 KB) +``` + +Tutte le immagini sono ≤ 1400 px larghezza e ≤ 1600 px altezza. Il file più pesante è quote-bg.jpg a 460 KB. Nessun file supera 500 KB. + +*Aggiornato dopo fix round 2 — i numeri precedenti (3.4 MB / nessun file >400 KB) erano errati: la pipeline originale non vincolava l'altezza, lasciando immagini portrait a 2-3 MB ciascuna.* + +--- + +## Note tecnica — PNG → JPG + +Le foto trainer e training erano estratte dal Figma come PNG lossless (6–8 MB ciascuna). Convertite a JPG qualità 85 con resize a 1200 px max → risparmio ~98% per file. I loghi partner restano PNG per preservare sfondo trasparente. + +Il file `asset-map.json` referenzia gli hash originali (estensione .png/.jpg come estratti). I file di destinazione finali hanno tutti estensione .jpg (eccetto loghi partner .png), il che diverge dai nomi originali nel map ma i file in `src/assets/img/` riflettono il formato convertito. + +--- + +## Conteggio + +- **Mappate e presenti:** 33 (31 in src/assets/img + 2 in public/img) +- **Mancanti nel Figma:** 2 (logo-dark, logo-light) → risolti con fallback +- **Immagini estratte totali:** 126 (inclusi ~66 duplicati) +- **Lasciate in assets-src/ (gitignored):** 126 file sorgente + +--- + +## Fix round 1 + +**Data:** 2026-07-02 + +### Cosa cambiato + +1. **`scripts/asset-map.json`** — rimossa sezione `"public/img"` (chiavi non-hash, rompeva pipeline dopo `extract-fig-images.sh`). Aggiunta sezione top-level `"fallbacks"` con struttura `{ "public/img": { "../Dati/logo-dark.png": "logo-dark.png", "../Dati/logo-light.png": "logo-light.png" } }`. Le destinazioni in `src/assets/img` mantengono l'estensione REALE del sorgente estratto da Figma (`.png` per PNG Figma, `.jpg` per JPEG già presenti in assets-src). + +2. **`scripts/apply-asset-map.mjs`** — aggiunto commento d'intestazione sull'ordine pipeline (extract → apply → optimize). Aggiunto loop `fallbacks`: copia da path relativo alla root del repo invece che da `assets-src/`, senza exit 1 per file assenti nel .fig. + +3. **`scripts/optimize-assets.sh`** (nuovo) — bash, `set -euo pipefail`. Converte tutti i PNG non-partner in `src/assets/img/` → JPG qualità 85 con resize `1400x>`, poi ridimensiona JPG già esistenti se larghezza > 1400px. Idempotente (nullglob su `.png`). + +### Output verifica riproducibilità + +``` +node scripts/apply-asset-map.mjs → "Copiate 33 immagini" +./scripts/optimize-assets.sh → 16 PNG→JPG, 5 JPG ridimensionati (hero-1, info-balance, info-longevity, method-step-1, method-step-2) +git status --short → elenco file identico al pre-fix (31 img + 4 PNG partner) + 18 file binari rigenerati (ricompressione diversa da conversione manuale originale) + → ricommittati: repo = output pipeline ✓ +``` + +### Esito punto 4 — bassa risoluzione + +- **`method-step-3.jpg`** (184×276): sorgente in `assets-src/a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg` è già 184×276 — stessa risoluzione del file committato. Nessuna versione ad alta risoluzione disponibile in `assets-src/`. **Richiede sorgente migliore dal cliente.** +- **`about-team-group.jpg`** (270×480): sorgente in `assets-src/32ecd00a20321e58c700fbaf739e53ac3bf03876.png` è già 270×480. Nessuna versione ad alta risoluzione disponibile. **Richiede sorgente migliore dal cliente.** + +--- + +## Fix round 2 + +**Data:** 2026-07-02 + +### Numeri finali + +``` +src/assets/img/ 3.7 MB totale (31 JPG + 4 PNG partner) +``` + +Top-5 per peso: +| File | Dimensioni | Peso | +|---|---|---| +| quote-bg.jpg | 1087×1600 | 460 KB | +| info-performance.jpg | 1200×1500 | 256 KB | +| info-longevity.jpg | 1095×1600 | 248 KB | +| training-performance-class.jpg | 1400×1400 | 240 KB | +| about-exterior.jpg | 1400×781 | 188 KB | + +Nessun file supera 500 KB. Precedente info-longevity.jpg era 2.7 MB, info-balance.jpg 2.0 MB. + +### Cosa cambiato + +1. **`scripts/optimize-assets.sh`** — vincolo resize cambiato da `'1400x>'` (solo larghezza) a `'1400x1600>'` (larghezza E altezza). Qualità JPG da 85 a 83. Tutti i JPG già esistenti vengono ri-codificati a q83 con nuovo vincolo (loop separato, skip file appena convertiti per evitare doppia codifica). Risultato: portrait da 2-3 MB → 248-460 KB. + +2. **`scripts/asset-map.json`** — aggiunta chiave `"_nota"` in testa per documentare la conversione PNG→JPG operata da optimize-assets.sh (JSON non supporta commenti, chiave dedicata con prefisso `_`). + +3. **`scripts/apply-asset-map.mjs`** — aggiunto `destDir.startsWith('_')` nella condizione di skip, così le chiavi meta `_*` del JSON vengono ignorate nella pipeline. + +### Esito idempotenza + +Terza esecuzione `apply-asset-map.mjs + optimize-assets.sh`: md5sum su tutti i .jpg identico prima e dopo → **IDEMPOTENTE** ✓ + +### Esito verifica loghi + +`public/img/logo-dark.png` (130×19, 1725 B) e `logo-light.png` (135×19, 1701 B) sono **identici** ai file sorgente in `../Dati/logo-dark.png` e `../Dati/logo-light.png` (stessa dimensione byte-level). Non sono placeholder: sono i loghi reali InsanityLab copiati dalla sezione `fallbacks` di asset-map.json tramite apply-asset-map.mjs. Le dimensioni in pixel ridotte (130×19 px) sono caratteristiche del PNG sorgente in Dati/, non di un errore di pipeline. + +--- + +## Fix round 3 + +**Data:** 2026-07-02 + +### Problema chiuso + +`public/img/logo-dark.png` e `logo-light.png` contenevano i loghi del template "PROWESS++" copiati da `../Dati/` (sorgente errato). Sostituiti con i loghi reali "insanitylab" estratti vettorialmente dal PDF Figma. + +### Metodo + +Sorgente: `../FigmaDoc/Homepage.pdf` (1334×10460 pt, 1 pagina). + +Render esplorativi a 72–150 dpi per individuare le zone logo: +- **Logo dark** (header, fondo bianco): riga nav in alto, x≈25pt, y≈10pt +- **Logo light** (footer, fondo scuro `srgb(58,41,41)`): sezione footer, y≈8490pt + +Estrazione finale con `pdftoppm -r 300`, poi ImageMagick: +- Dark: `-fuzz 8% -transparent white -trim -resize 600x -strip` +- Light: `-crop 850x660+130+60` (esclude tagline e colonne destra), `-fuzz 12% -transparent 'srgb(58,41,41)' -trim -resize 600x -strip` + +`-strip` su entrambi garantisce idempotenza md5sum tra esecuzioni successive. + +### Coordinate pdftoppm a 300 dpi (pixel) + +| Logo | -x | -y | -W | -H | crop interno | +|---|---|---|---|---|---| +| dark | 104 | 41 | 895 | 250 | — | +| light | 0 | 35375 | 1800 | 833 | `850x660+130+60` | + +### Dimensioni finali + +| File | Dimensioni | Tipo | +|---|---|---| +| `public/img/logo-dark.png` | **600×108 px** | TrueColorAlpha (PNG trasparente) | +| `public/img/logo-light.png` | **600×342 px** | TrueColorAlpha (PNG trasparente) | + +### Aggiornamenti pipeline + +- Creato `scripts/extract-logos.sh` (bash, `set -euo pipefail`, coordinate fisse, idempotente) +- `scripts/asset-map.json`: rimossa sezione `fallbacks` (loghi ora da extract-logos.sh, non da `../Dati`) +- `scripts/apply-asset-map.mjs`: commento pipeline aggiornato a step 4 +- Ordine pipeline: `extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh` + +### Verifica visiva + +- `logo-dark.png`: icona onda/battito cardiaco beige + scritta "insanitylab" scura, layout orizzontale, sfondo trasparente. **Nessun residuo PROWESS++.** +- `logo-light.png`: icona onda/battito cardiaco beige sopra, scritta "insanitylab" beige sotto, layout stacked, sfondo trasparente. **Nessun residuo PROWESS++.** + +### Idempotenza + +``` +md5sum run1 = md5sum run2 → IDEMPOTENTE ✓ +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..118dd1b --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# InsanityLab Website + +Sito vetrina per InsanityLab con blog integrato e form di contatti. + +## Descrizione + +Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 5** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages. + +## Stack Tecnologico + +- **Framework**: Astro 5 +- **Runtime**: Node.js (adapter standalone) +- **Database**: SQLite (better-sqlite3) +- **Autenticazione**: bcryptjs +- **Email**: Nodemailer +- **Markup Editor**: TipTap (rich text) +- **Font System**: @fontsource (Montserrat, Open Sans) +- **Sitemap**: @astrojs/sitemap +- **Testing**: Vitest +- **Linguaggio**: TypeScript + +## Installazione + +### Prerequisiti + +- Node.js 18+ e npm + +### Setup + +```bash +# Installa dipendenze +npm install + +# Copia il template di configurazione env +cp .env.example .env + +# Crea il primo utente amministratore (dopo aver configurato il DB) +npm run create-user -- admin tuapassword +``` + +Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente. + +## Comandi Disponibili + +| Comando | Descrizione | +|---------|-------------| +| `npm run dev` | Avvia il server di sviluppo (localhost:3000) | +| `npm run build` | Compila il progetto per production | +| `npm run preview` | Visualizza l'anteprima della build (locale) | +| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) | +| `npm run test` | Esegui i test con Vitest | +| `npm run create-user` | Crea un nuovo utente (admin o redattore) | + +## Struttura Progetto + +``` +insanitylab-website/ +├── src/ +│ ├── pages/ # Pagine Astro (routing automatico) +│ ├── components/ # Componenti Astro riutilizzabili +│ ├── layouts/ # Layout master +│ ├── utils/ # Funzioni di utilità +│ ├── env.d.ts # Type definitions globali +│ └── ... +├── data/ # Database SQLite e dati persistenti +│ └── insanitylab.db # (generato al primo avvio) +├── uploads/ # Directory per upload immagini e file +├── dist/ # Output build (server) +├── tests/ # Test suite +├── astro.config.mjs # Configurazione Astro +├── tsconfig.json # Configurazione TypeScript +├── vitest.config.ts # Configurazione test framework +├── .env.example # Template variabili di ambiente +├── .env # Variabili di ambiente (non versionato) +├── package.json # Dipendenze e script npm +└── README.md # Questo file +``` + +## Configurazione Ambiente + +Il file `.env` richiede le seguenti variabili: + +- `SMTP_HOST`: Host SMTP per invio email +- `SMTP_PORT`: Porta SMTP (solitamente 587) +- `SMTP_USER`: Utente SMTP +- `SMTP_PASS`: Password SMTP +- `CONTACT_TO`: Email di destinazione contatti +- `CONTACT_FROM`: Email mittente +- `DB_PATH`: Percorso database SQLite +- `UPLOADS_DIR`: Cartella upload file + +## Development Workflow + +1. **Avvia il server locale**: `npm run dev` +2. **Modifica file** in `src/` (hot reload automatico) +3. **Test**: `npm run test` +4. **Build**: `npm run build` (verifica errori di build) +5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione) + +## Deployment + +```bash +# Build per production +npm run build + +# Opzionalmente, preview locale +npm run preview + +# In production, il server parte con: +npm run start + +# Variabili ambiente in production vanno impostate sull'hosting +# (es. Hostinger VPS, Vercel, etc.) +``` + +## Riferimenti Tecnici + +Per dettagli su architettura, schema database, componenti e specifiche vedi: +- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md` +- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md` + +## Problemi Comuni + +### `better-sqlite3` fallisce su npm install + +Se `better-sqlite3` non compila, assicurati di avere build tools installati: +- **Linux**: `sudo apt-get install build-essential python3` +- **macOS**: Xcode Command Line Tools (`xcode-select --install`) +- **Windows**: Visual Studio Build Tools o MinGW + +### Database locked + +Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`. + +## Licenza + +Proprietario InsanityLab. diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..3c44492 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,10 @@ +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()], +}); diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/superpowers/plans/2026-07-01-insanitylab-website.md b/docs/superpowers/plans/2026-07-01-insanitylab-website.md new file mode 100644 index 0000000..808bfa1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-insanitylab-website.md @@ -0,0 +1,3513 @@ +# 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 +/// + +declare namespace App { + interface Locals { + user?: { id: number; username: string }; + } +} +``` + +`src/pages/index.astro` (placeholder, sostituito nel Task 9): +```astro +--- +export const prerender = true; +--- +InsanityLab +

InsanityLab — in costruzione

+``` + +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 -- `), 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": { + ".jpg": "hero-1.jpg", + ".jpg": "info-performance.jpg", + ".jpg": "info-balance.jpg", + ".jpg": "info-longevity.jpg", + ".jpg": "feature-performance.jpg", + ".jpg": "feature-balance.jpg", + ".jpg": "feature-longevity.jpg", + ".jpg": "quote-bg.jpg", + ".jpg": "training-one-to-one.jpg", + ".jpg": "training-small-groups.jpg", + ".jpg": "training-performance-class.jpg", + ".jpg": "training-fit-remote.jpg", + ".jpg": "team-donata.jpg", + ".jpg": "team-nicola.jpg", + ".jpg": "team-eleonora.jpg", + ".jpg": "team-antonello.jpg", + ".jpg": "method-step-1.jpg", + ".jpg": "method-step-2.jpg", + ".jpg": "method-step-3.jpg", + ".jpg": "method-step-4.jpg", + ".png": "partner-edenred.png", + ".png": "partner-welbee.png", + ".png": "partner-aon.png", + ".png": "partner-howden.png" + }, + "public/img": { + ".png": "logo-dark.png", + ".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(); + +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: '

corpo

', 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 -- '); + 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: `` layout con slot; `` 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; +--- + + + + + + {title} — InsanityLab + + + + + + + + +
+
+