Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21d9755e37 | |||
| ee54e9a1f4 | |||
| 62e0120ea2 | |||
| 2f94fc0373 | |||
| e6c493b756 | |||
| 1522d97f54 | |||
| cc21cb7627 | |||
| 27fdf1fc30 | |||
| ae5eef037b | |||
| 931c1e43ad | |||
| a7b05d703a | |||
| 4cee8cdd7a | |||
| 1742c1d666 | |||
| 0b938d7d09 | |||
| 47a93049bb | |||
| 5ff47711cd | |||
| f48518d8fd | |||
| 30b509436e | |||
| 8f47cf9e94 | |||
| 9f6cf29708 | |||
| 3183bd795d | |||
| 1b9a3c1343 | |||
| 3b8983dca5 | |||
| 3b4d38e087 | |||
| 02b5180227 | |||
| c722680590 | |||
| 172cef236b | |||
| 1aa82f5332 | |||
| d79aa912b1 | |||
| 86d608a929 | |||
| 708410bbf1 | |||
| 53af0ca00e | |||
| 6594f6da53 | |||
| c6de02be83 | |||
| 11fc2b3e54 | |||
| 5b34ee1697 | |||
| 2468ce95f7 | |||
| 9966ec3616 | |||
| 140146fce1 | |||
| 7211d74d3a | |||
| e2bbc9074f | |||
| be77285613 | |||
| c36ad4cb2d | |||
| 0f2b2ba014 | |||
| 2bf6b3d4de | |||
| 22d72be74d | |||
| 842371604b | |||
| 71cd1626e0 | |||
| 6b85872515 | |||
| 5ce10da9bd | |||
| c3267c9be2 | |||
| 6d1e2607ee | |||
| db92fc9c02 | |||
| e87a464090 | |||
| 5cea895d51 | |||
| d4cf359805 | |||
| d0fa75a3d3 | |||
| 6c225281e0 | |||
| 8ceea9b16f | |||
| c975dcac12 | |||
| 8495d9edd4 | |||
| 56ca6eca14 | |||
| 608b1b68d2 | |||
| be52781499 | |||
| bf19cba4d3 | |||
| eda4d73aac | |||
| b5c0c107bc | |||
| 685ef4a36e | |||
| 3551fae132 | |||
| bf38fc865c | |||
| 31354c4457 | |||
| b84463c9df | |||
| d5b19f2c39 | |||
| 34fd3d2e18 | |||
| 3f01721a99 | |||
| f4c350ad64 | |||
| b079ded266 | |||
| b22cc40b03 | |||
| 6f51aec4c6 | |||
| 5f1d9f3740 | |||
| 0f94b5017c | |||
| 274a6c7600 | |||
| 77041f9d5a | |||
| c7d9d4ba5d | |||
| 83237feb65 | |||
| b83d62c10c | |||
| 875b369939 | |||
| 98c42aae54 | |||
| 7d2f0b7048 | |||
| ecdf12ee0e | |||
| 2d5ff1816d | |||
| 6cc1cf5f44 | |||
| 0d9b645636 | |||
| e54daa105f | |||
| eccc7ee237 | |||
| 3c949da75a | |||
| 6d1963737e | |||
| 29d633c795 | |||
| f0ad162f74 | |||
| f3fb2fe005 | |||
| e9840105a3 | |||
| be4d933ca3 | |||
| ed35491d4c | |||
| c4cb05e142 | |||
| ba89568a38 | |||
| 45e5214417 | |||
| 2fdd1b9a7f | |||
| ac75720bd9 | |||
| 50ce224d11 | |||
| 6caba6fee1 | |||
| d3b5e67132 | |||
| 42d603b55b | |||
| c9a9ff91fd | |||
| 4445c422f8 | |||
| 810e5d8dcd | |||
| 551957e2e1 | |||
| d8ef59e370 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
uploads
|
||||
.env
|
||||
.git
|
||||
docs
|
||||
assets-src
|
||||
tests
|
||||
*.md
|
||||
@@ -1,8 +1,17 @@
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=user
|
||||
SMTP_PASS=pass
|
||||
# SMTP Aruba — il dominio insanitylab.it invia la posta via Aruba (SPF: include:aruba.it)
|
||||
# Server posta in uscita autenticato: smtps.aruba.it:465 (SSL). Verificato funzionante.
|
||||
SMTP_HOST=smtps.aruba.it
|
||||
SMTP_PORT=465
|
||||
# Casella reale del dominio usata per l'invio autenticato (con la sua password Aruba)
|
||||
SMTP_USER=postmaster@insanitylab.it
|
||||
SMTP_PASS=la-password-della-casella
|
||||
# Aruba impone che il mittente sia la casella autenticata: uguale a SMTP_USER
|
||||
CONTACT_FROM=postmaster@insanitylab.it
|
||||
# Dove arrivano i messaggi del form
|
||||
CONTACT_TO=info@insanitylab.it
|
||||
CONTACT_FROM=sito@insanitylab.it
|
||||
DB_PATH=data/insanitylab.db
|
||||
UPLOADS_DIR=uploads
|
||||
|
||||
# Google Tag Manager: ID container preso da tagmanager.google.com (accanto al nome container).
|
||||
# Formato GTM-XXXXXXX. Lascia vuoto per non caricare GTM (es. in locale/staging).
|
||||
GTM_ID=GTM-XXXXXXX
|
||||
|
||||
@@ -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 ✓
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# Stage 1: build
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: runtime
|
||||
FROM node:22-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=4321 \
|
||||
DB_PATH=/app/data/insanitylab.db \
|
||||
UPLOADS_DIR=/app/uploads
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -1,137 +1,154 @@
|
||||
# InsanityLab Website
|
||||
|
||||
Sito vetrina per InsanityLab con blog integrato e form di contatti.
|
||||
Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**.
|
||||
|
||||
## Descrizione
|
||||
|
||||
Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 5** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages.
|
||||
Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor rich‑text, e il form di contatti invia email tramite SMTP.
|
||||
|
||||
## Stack Tecnologico
|
||||
## Stack tecnologico
|
||||
|
||||
- **Framework**: Astro 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
|
||||
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
|
||||
- **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone)
|
||||
- **Database**: SQLite via `better-sqlite3`
|
||||
- **Autenticazione**: sessioni con password hashate (`bcryptjs`)
|
||||
- **Email**: Nodemailer (SMTP)
|
||||
- **Editor articoli**: TipTap (rich text)
|
||||
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
|
||||
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
|
||||
- **Test**: Vitest
|
||||
- **Linguaggio**: TypeScript
|
||||
- **Deploy**: Docker (multi‑stage, `node:22-slim`) + Traefik su VPS
|
||||
|
||||
## Installazione
|
||||
## Funzionalità principali
|
||||
|
||||
- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi".
|
||||
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
|
||||
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
|
||||
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo.
|
||||
- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`).
|
||||
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
|
||||
|
||||
## Installazione (sviluppo)
|
||||
|
||||
### Prerequisiti
|
||||
|
||||
- Node.js 18+ e npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Installa dipendenze
|
||||
npm install
|
||||
|
||||
# Copia il template di configurazione env
|
||||
cp .env.example .env
|
||||
|
||||
# Crea il primo utente amministratore (dopo aver configurato il DB)
|
||||
npm run create-user -- admin tuapassword
|
||||
cp .env.example .env # poi compila i valori reali
|
||||
npm run create-user -- admin <password> admin
|
||||
npm run dev # http://localhost:4321
|
||||
```
|
||||
|
||||
Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente.
|
||||
Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato automaticamente con i contenuti di default (seed) e le migrazioni idempotenti.
|
||||
|
||||
## Comandi Disponibili
|
||||
## Comandi
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Avvia il server di sviluppo (localhost: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) |
|
||||
| `npm run dev` | Server di sviluppo (localhost:4321) |
|
||||
| `npm run build` | Build di produzione |
|
||||
| `npm run preview` | Anteprima locale della build |
|
||||
| `npm run start` | Server di produzione (`node --env-file=.env`) |
|
||||
| `npm run test` | Test con Vitest |
|
||||
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user]` |
|
||||
|
||||
## Struttura Progetto
|
||||
## Gestione contenuti
|
||||
|
||||
I testi e le immagini della vetrina sono **blocchi taggati** salvati nella tabella `content_blocks`. Ogni blocco ha un tag stabile (es. `home.hero.title-line-1`), un valore, eventuali stili predefiniti, un tipo (`text` / `html` / `image`) e un **GUID** univoco.
|
||||
|
||||
- **Pannello** `/admin/content`: i blocchi sono raggruppati per pagina, con ricerca per tag o contenuto, upload delle immagini e ripristino all'originale.
|
||||
- **Modifica inline**: attivando "Mostra tag" dal menu utente, ogni blocco mostra un badge (nome tag + matita) che apre una finestra di modifica direttamente in pagina.
|
||||
- **Componenti** `<T>` e `<TImg>`: rendono i blocchi con fallback automatico al seed quando un tag non è ancora nel database.
|
||||
|
||||
Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui contenuti già esistenti (rinomine, testi aggiornati) si applicano tramite **migrazioni idempotenti** in `src/lib/db.ts`, così da non sovrascrivere le modifiche fatte dal pannello.
|
||||
|
||||
> Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`).
|
||||
|
||||
## Ruoli e accessi
|
||||
|
||||
Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
|
||||
|
||||
| Ruolo | Può fare |
|
||||
|-------|----------|
|
||||
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) |
|
||||
| `superuser` | Pannello contenuti + blog |
|
||||
| `user` | Solo i **propri** articoli del blog |
|
||||
|
||||
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
insanitylab-website/
|
||||
├── src/
|
||||
│ ├── pages/ # Pagine Astro (routing automatico)
|
||||
│ ├── components/ # Componenti Astro riutilizzabili
|
||||
│ ├── layouts/ # Layout master
|
||||
│ ├── utils/ # Funzioni di utilità
|
||||
│ ├── env.d.ts # Type definitions globali
|
||||
│ └── ...
|
||||
├── data/ # Database SQLite e dati persistenti
|
||||
│ └── insanitylab.db # (generato al primo avvio)
|
||||
├── uploads/ # Directory per upload immagini e file
|
||||
├── dist/ # Output build (server)
|
||||
├── tests/ # Test suite
|
||||
├── astro.config.mjs # Configurazione Astro
|
||||
├── tsconfig.json # Configurazione TypeScript
|
||||
├── vitest.config.ts # Configurazione test framework
|
||||
├── .env.example # Template variabili di ambiente
|
||||
├── .env # Variabili di ambiente (non versionato)
|
||||
├── package.json # Dipendenze e script npm
|
||||
└── README.md # Questo file
|
||||
│ ├── pages/ # Route Astro (vetrina, /admin, /api)
|
||||
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
|
||||
│ ├── layouts/ # Layout Base
|
||||
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
|
||||
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
|
||||
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
||||
│ └── styles/ # global.css
|
||||
├── data/ # SQLite (volume in produzione)
|
||||
├── uploads/ # Upload immagini (volume in produzione)
|
||||
├── scripts/ # create-user, smoke test
|
||||
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
|
||||
├── compose.yaml # Stack Docker + label Traefik
|
||||
├── Dockerfile # Build multi-stage
|
||||
├── .env.example # Template variabili d'ambiente
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Configurazione Ambiente
|
||||
## Variabili d'ambiente
|
||||
|
||||
Il file `.env` richiede le seguenti variabili:
|
||||
Il file `.env` (non versionato) richiede:
|
||||
|
||||
- `SMTP_HOST`: Host SMTP per invio email
|
||||
- `SMTP_PORT`: Porta SMTP (solitamente 587)
|
||||
- `SMTP_USER`: Utente SMTP
|
||||
- `SMTP_PASS`: Password SMTP
|
||||
- `CONTACT_TO`: Email di destinazione contatti
|
||||
- `CONTACT_FROM`: Email mittente
|
||||
- `DB_PATH`: Percorso database SQLite
|
||||
- `UPLOADS_DIR`: Cartella upload file
|
||||
| Variabile | Descrizione |
|
||||
|-----------|-------------|
|
||||
| `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) |
|
||||
| `SMTP_PORT` | Porta SMTP (`465` con SSL) |
|
||||
| `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) |
|
||||
| `SMTP_PASS` | Password della casella |
|
||||
| `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) |
|
||||
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
||||
| `DB_PATH` | Percorso del database SQLite |
|
||||
| `UPLOADS_DIR` | Cartella degli upload |
|
||||
|
||||
## Development Workflow
|
||||
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
|
||||
|
||||
1. **Avvia il server locale**: `npm run dev`
|
||||
2. **Modifica file** in `src/` (hot reload automatico)
|
||||
3. **Test**: `npm run test`
|
||||
4. **Build**: `npm run build` (verifica errori di build)
|
||||
5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione)
|
||||
## Deploy in produzione
|
||||
|
||||
## Deployment
|
||||
Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/insanitylab/` (repo in `src/`, volumi `data/` e `uploads/`). Aggiornamento:
|
||||
|
||||
```bash
|
||||
# Build per production
|
||||
npm run build
|
||||
|
||||
# Opzionalmente, preview locale
|
||||
npm run preview
|
||||
|
||||
# In production, il server parte con:
|
||||
npm run start
|
||||
|
||||
# Variabili ambiente in production vanno impostate sull'hosting
|
||||
# (es. Hostinger VPS, Vercel, etc.)
|
||||
cd /opt/docker/insanitylab/src
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Riferimenti Tecnici
|
||||
Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello.
|
||||
|
||||
Per dettagli su architettura, schema database, componenti e specifiche vedi:
|
||||
- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
|
||||
- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md`
|
||||
### Dominio e DNS
|
||||
|
||||
## Problemi Comuni
|
||||
- Dominio ufficiale: **insanitylab.it** (canonico l'apex; `www` fa redirect 301 all'apex tramite Traefik).
|
||||
- I record **A** di `@` e `www` puntano al VPS; la **posta resta su Aruba** (MX, SPF, webmail non vengono toccati).
|
||||
- TLS Let's Encrypt automatico via Traefik (certresolver `mytlschallenge`).
|
||||
|
||||
### `better-sqlite3` fallisce su npm install
|
||||
> Attenzione dietro Traefik: `allowedDomains` in `astro.config.mjs` deve elencare i domini serviti, altrimenti Astro ignora `X-Forwarded-Proto/Host` e il controllo CSRF respinge i POST con 403.
|
||||
|
||||
Se `better-sqlite3` non compila, assicurati di avere build tools installati:
|
||||
- **Linux**: `sudo apt-get install build-essential python3`
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: Visual Studio Build Tools o MinGW
|
||||
## Problemi comuni
|
||||
|
||||
### Database locked
|
||||
**`better-sqlite3` non compila su `npm install`** — servono i build tools:
|
||||
- Linux: `sudo apt-get install build-essential python3`
|
||||
- macOS: `xcode-select --install`
|
||||
- Windows: Visual Studio Build Tools
|
||||
|
||||
Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`.
|
||||
**Modifiche allo `<style>` scoped di `[slug].astro` non compaiono in dev** — l'HMR di Astro 7 non sempre ricompila lo stile scoped delle route dinamiche: riavviare il dev server (`astro dev stop` + `npm run dev`).
|
||||
|
||||
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietario InsanityLab.
|
||||
Proprietà di InsanityLab.
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://insanitylab.tielogic.xyz',
|
||||
site: 'https://insanitylab.it',
|
||||
output: 'server',
|
||||
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
|
||||
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
|
||||
// Dominio ufficiale insanitylab.it (canonico apex); www e il vecchio
|
||||
// sottodominio tielogic.xyz redirigono all'apex a livello Traefik.
|
||||
security: {
|
||||
allowedDomains: [
|
||||
{ hostname: 'insanitylab.it', protocol: 'https' },
|
||||
{ hostname: 'www.insanitylab.it', protocol: 'https' },
|
||||
{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' },
|
||||
],
|
||||
},
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [sitemap()],
|
||||
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
|
||||
redirects: {
|
||||
'/programs': { status: 301, destination: '/services' },
|
||||
'/programs/[slug]': { status: 301, destination: '/services/[slug]' },
|
||||
// Modifiche 02: "Pilates Flow" → "Profilazione". "Rehab" esce dalla griglia Servizi e
|
||||
// diventa un riquadro Training, ma la pagina dettaglio resta su /services/rehab.
|
||||
'/services/pilates-flow': { status: 301, destination: '/services/profilazione' },
|
||||
// "Personal Training" rinominato "One to One" (nuovo slug); Small Group è una pagina a sé.
|
||||
'/services/personal-training': { status: 301, destination: '/services/one-to-one' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
insanitylab:
|
||||
build: .
|
||||
image: insanitylab-website:latest
|
||||
container_name: insanitylab
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- DB_PATH=/app/data/insanitylab.db
|
||||
- UPLOADS_DIR=/app/uploads
|
||||
volumes:
|
||||
- /opt/docker/insanitylab/data:/app/data
|
||||
- /opt/docker/insanitylab/uploads:/app/uploads
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Router principale: solo il dominio ufficiale canonico (apex insanitylab.it)
|
||||
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.it`)
|
||||
- traefik.http.routers.insanitylab.tls=true
|
||||
- traefik.http.routers.insanitylab.entrypoints=websecure
|
||||
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
|
||||
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
|
||||
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
|
||||
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`)
|
||||
- traefik.http.routers.insanitylab-redir.tls=true
|
||||
- traefik.http.routers.insanitylab-redir.entrypoints=websecure
|
||||
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.insanitylab-redir.service=insanitylab
|
||||
- traefik.http.routers.insanitylab-redir.middlewares=insanitylab-tocanon
|
||||
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.regex=^https?://[^/]+/(.*)
|
||||
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.replacement=https://insanitylab.it/$${1}
|
||||
- traefik.http.middlewares.insanitylab-tocanon.redirectregex.permanent=true
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
@@ -1,130 +0,0 @@
|
||||
# Sito web InsanityLab — Design
|
||||
|
||||
**Data:** 2026-07-01
|
||||
**Dominio di destinazione:** insanitylab.tielogic.xyz (VPS Hostinger, porting in fase successiva)
|
||||
**Stato:** approvato dall'operatore durante la sessione di brainstorming
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Realizzare il sito vetrina di InsanityLab (centro di allenamento e benessere, Bologna) a partire dal design Figma fornito dal designer (`FigmaDoc/*.pdf`, `Web Insanity.fig`), che ristilizza il template WordPress "Prowess" (analizzato in `Dati/cassetta-me-analisi.md`) con una palette beige/marrone al posto del rosso originale. Il sito include un blog gestibile dal cliente tramite pannello protetto da login e un form contatti che invia email. La gestione degli acquisti online (pagina prezzi Performance Class) è rimandata a una fase successiva.
|
||||
|
||||
## Stack
|
||||
|
||||
| Livello | Scelta |
|
||||
|---|---|
|
||||
| Framework | Astro 5, output `server` con adapter Node |
|
||||
| Pagine vetrina | Prerenderizzate (`prerender = true`) |
|
||||
| Blog e admin | Server-rendered |
|
||||
| Stile | CSS custom (palette estratta dai PDF Figma), nessun framework CSS |
|
||||
| Font | Montserrat (titoli) + Open Sans (corpo), self-hosted |
|
||||
| JS client | Vanilla, minimo indispensabile (slider, tab, accordion, header sticky) |
|
||||
| Database | SQLite via `better-sqlite3`, file `data/insanitylab.db` |
|
||||
| Editor admin | WYSIWYG TipTap, con upload immagini nel corpo articolo |
|
||||
| Email | nodemailer → SMTP (credenziali via `.env`) |
|
||||
| Test | vitest (logica) + smoke test su build |
|
||||
| Versionamento | git dal primo commit, remote su GitLab |
|
||||
| Deploy (fase 2) | Dockerfile multi-stage + compose, gestito poi via Komodo sul VPS |
|
||||
|
||||
Motivazione: sito prevalentemente statico con una sola area dinamica (blog). Un'unica applicazione Node leggera evita la manutenzione di un CMS completo, mantiene ottime prestazioni e SEO, e resta estendibile (pagamenti in fase futura).
|
||||
|
||||
## Route
|
||||
|
||||
### Pubbliche
|
||||
|
||||
| Route | Contenuto | Fonte design |
|
||||
|---|---|---|
|
||||
| `/` | Homepage: hero slider, 3 colonne Performance/Balance/Longevity, feature alternata, banner citazione, Training (4 card), Programmi (6 icone), Agenda con tab, Team trainer, Metodo (4 step), loghi partner (Ticket Welfare Edenred, Welbee, AON, Howden), footer con mini-form | Homepage.pdf |
|
||||
| `/about` | Mission, direzione, approccio, about, timeline "Storylab" (2021-2024 aperta; +2025, +2026, +oggi ad accordion), convenzioni aziendali, team completo (9 persone), metodo, IN-SANITY STUDIOS, vision | Insanitylab.pdf |
|
||||
| `/training` | 4 servizi (One to One, Small Groups, Performance Class, Fit Remote) con blocchi alternati, agenda, CTA "prenota la lezione di prova" | Training.pdf |
|
||||
| `/programs` | Griglia 6 programmi con testi reali | Programmi.pdf |
|
||||
| `/programs/[slug]` | Pagina dettaglio per ogni programma: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition` | Performance class.pdf |
|
||||
| `/blog` | Blog & Edugo: intro, filtri per categoria, griglia articoli, paginazione | Blog & Edugo.pdf |
|
||||
| `/blog/[slug]` | Articolo singolo con sidebar (autore, categorie, ultimi articoli, archivio) | Articolo.pdf |
|
||||
| `/contact` | Form contatti (Nome, Cognome, Telefono, E-mail, Messaggio), mappa Google, recapiti | Contatti.pdf |
|
||||
|
||||
Dettaglio programmi: layout unico modellato su Performance class.pdf (FAQ ad accordion, descrizione, sidebar categorie + altri programmi). Il blocco pricing compare solo dove esistono prezzi — al lancio solo `performance-class` (3 piani semestrali: 130/234/300 € al mese, con varianti trimestrale e mensile ad accordion). I bottoni "acquista" puntano per ora a `/contact`. I testi degli altri cinque programmi partono dalle descrizioni di Programmi.pdf; il cliente li integrerà.
|
||||
|
||||
### Admin (richiedono sessione)
|
||||
|
||||
| Route | Funzione |
|
||||
|---|---|
|
||||
| `/admin/login` | Login user/password |
|
||||
| `/admin` | Elenco articoli (pubblicati e bozze) |
|
||||
| `/admin/new` | Nuovo articolo |
|
||||
| `/admin/edit/[id]` | Modifica articolo |
|
||||
|
||||
### API
|
||||
|
||||
| Endpoint | Funzione |
|
||||
|---|---|
|
||||
| `POST /api/contact` | Invio email form contatti (honeypot antispam + rate-limit) |
|
||||
| `POST/PUT/DELETE /api/admin/posts*` | CRUD articoli (sessione richiesta) |
|
||||
| `POST /api/admin/upload` | Upload immagini (copertina e corpo articolo, salvate in `public/uploads/`) |
|
||||
|
||||
## Design system
|
||||
|
||||
- **Palette** (valori esatti da campionare dai PDF in fase di implementazione): accento beige/tan ~`#b8a98a` (bottoni, etichette), marrone scuro ~`#3a2e28` (sezioni dark: agenda, banner citazione, footer, card scure), fondi chiari `#f5f3f0`/bianco.
|
||||
- **Tipografia**: Montserrat uppercase con letterspacing largo per titoli e bottoni; Open Sans per il corpo. File woff2 self-hosted (niente Google Fonts CDN, conformità GDPR).
|
||||
- **Componenti chiave**: header sticky (trasparente sopra l'hero, bianco con ombra allo scroll), hero slider (scroll-snap CSS + JS vanilla), card programma testo/foto alternate, agenda settimanale con tab per disciplina, accordion FAQ e pricing, form con stati di validazione.
|
||||
- **Responsive**: mobile-first; il Figma è solo desktop (1366 px), il comportamento mobile si deriva dalle convenzioni del template (menu hamburger sotto i 992 px, griglie che collassano a colonna).
|
||||
- **Immagini**: estratte dall'archivio `Web Insanity.fig` (è uno ZIP con cartella `images/`; i file hanno nomi hash e vanno identificati visivamente e rinominati per sezione). Fallback: asset in `Dati/` e estrazione da PDF con `pdfimages`. Ottimizzazione con `astro:assets` (webp, lazy loading, srcset).
|
||||
- **SEO**: title/description per pagina, Open Graph, sitemap, robots.txt, favicon dal logo.
|
||||
- **Mappa contatti**: iframe Google Maps con click-to-load (consenso prima del caricamento, GDPR).
|
||||
|
||||
## Backend
|
||||
|
||||
### Database
|
||||
|
||||
SQLite in `data/insanitylab.db` (fuori da `src/`, montabile come volume Docker in fase 2). Schema creato da script SQL idempotente all'avvio.
|
||||
|
||||
```sql
|
||||
posts (id, title, slug UNIQUE, category, excerpt, body_html,
|
||||
cover, published_at, draft, created_at, updated_at)
|
||||
users (id, username UNIQUE, password_hash)
|
||||
sessions (token PK, user_id, expires_at)
|
||||
```
|
||||
|
||||
Categorie blog fisse (da Figma): Articoli tecnici & scientifici, Contenuti educativi, LinkedIn / Insight, Eventi & Presidi.
|
||||
|
||||
Il corpo articolo è HTML prodotto da TipTap, sanitizzato lato server prima del salvataggio e del rendering.
|
||||
|
||||
### Autenticazione
|
||||
|
||||
- Password con hash bcrypt; utenti creati via script CLI (`npm run create-user`).
|
||||
- Sessione: token random in cookie httpOnly, SameSite=Lax, scadenza 7 giorni, verificata da middleware su tutte le route `/admin` e `/api/admin`.
|
||||
- Rate-limit sul login (protezione brute force).
|
||||
|
||||
### Email
|
||||
|
||||
`POST /api/contact` valida i campi, applica honeypot e rate-limit, invia via nodemailer con SMTP configurato in `.env` (host, porta, credenziali, destinatario — default `info@insanitylab.it`). Risposta JSON per feedback inline nel form.
|
||||
|
||||
### Dati statici
|
||||
|
||||
Agenda settimanale, prezzi, team, programmi, loghi partner: file TypeScript/JSON in `src/data/`, modificati dallo sviluppatore e versionati in git. Nessuna gestione da admin per questi contenuti al lancio.
|
||||
|
||||
## Deploy
|
||||
|
||||
- **Fase attuale (locale)**: `npm run dev`; build verificata con `npm run build && npm run preview`.
|
||||
- **Versionamento**: repository git con remote su GitLab (namespace da confermare in fase di implementazione).
|
||||
- **Fase 2 (VPS)**: Dockerfile multi-stage (build → `node:22-slim`), `compose.yaml` con volumi per `data/` e `public/uploads/`, porta interna 4321; aggancio a Komodo e reverse proxy per `insanitylab.tielogic.xyz` quando si porterà in produzione. Fuori scope di questa spec.
|
||||
|
||||
## Gestione errori
|
||||
|
||||
- Form contatti: validazione client + server, messaggi di errore inline, fallback se SMTP non risponde (errore esplicito all'utente, log lato server).
|
||||
- Admin: redirect a `/admin/login` se sessione assente/scaduta; conferma prima dell'eliminazione articoli.
|
||||
- 404 personalizzata coerente col design.
|
||||
- Upload: limite dimensione (5 MB) e whitelist tipi immagine.
|
||||
|
||||
## Test e verifica
|
||||
|
||||
- vitest su: helper slug, validazione form, auth (hash/sessioni), query posts.
|
||||
- Smoke test: build completa senza errori; fetch delle route principali con risposta 200 e contenuti attesi.
|
||||
- Verifica visiva locale contro i PDF Figma pagina per pagina.
|
||||
|
||||
## Fuori scope (fasi future)
|
||||
|
||||
- Pagamenti online piani Performance Class (Stripe o simile).
|
||||
- Gestione da admin di agenda, prezzi, team.
|
||||
- Porting su VPS, DNS, TLS (fase 2 dedicata).
|
||||
- Ricerca nel blog e ricerca globale (icona lente del template: omessa al lancio).
|
||||
- E-commerce/carrello (icona carrello nell'header Figma: omessa al lancio, era ereditata dal template).
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.0.1",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@tiptap/core": "^3.27.1",
|
||||
@@ -27,6 +26,7 @@
|
||||
"sanitize-html": "^2.17.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#3a2929"/>
|
||||
<path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b8a98c" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
|
||||
@@ -0,0 +1,42 @@
|
||||
// Pipeline asset: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
|
||||
// 1. extract-fig-images.sh: estrae hash-named images da .fig → assets-src/
|
||||
// 2. apply-asset-map.mjs: copia da assets-src/ → destinazioni (src/assets/img, public/img)
|
||||
// 3. optimize-assets.sh: converte PNG fotografici → JPG q85, ridimensiona ≤1400px
|
||||
// 4. extract-logos.sh: estrae loghi insanitylab da FigmaDoc/Homepage.pdf → public/img/logo-{dark,light}.png
|
||||
|
||||
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;
|
||||
|
||||
// Sezione principale: copia da assets-src/
|
||||
for (const [destDir, entries] of Object.entries(map)) {
|
||||
if (destDir.startsWith('_') || destDir === 'fallbacks') continue;
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sezione fallbacks: copia da path relativo alla root del repo (es. ../Dati/)
|
||||
if (map.fallbacks) {
|
||||
for (const [destDir, entries] of Object.entries(map.fallbacks)) {
|
||||
mkdirSync(join(root, destDir), { recursive: true });
|
||||
for (const [srcRelPath, dest] of Object.entries(entries)) {
|
||||
const from = join(root, srcRelPath);
|
||||
if (!existsSync(from)) { console.error(`MANCANTE (fallback): ${srcRelPath}`); missing++; continue; }
|
||||
copyFileSync(from, join(root, destDir, dest));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
|
||||
process.exit(missing ? 1 : 0);
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"_nota": "Le destinazioni .png fotografiche vengono convertite in .jpg da optimize-assets.sh; il nome finale su disco ha estensione .jpg",
|
||||
"src/assets/img": {
|
||||
"8b71f158c30a0a23cc5113bfe1cd9a6c4f19e4c4.jpg": "hero-1.jpg",
|
||||
"0f8034394657985cc4cf35eb8441ae93a7ce5302.jpg": "info-performance.jpg",
|
||||
"432d397a342b4e8312a3a13a4fea836b3e867a64.jpg": "info-balance.jpg",
|
||||
"d7fcf0aa827ec50f660145c0d319049b9153fefb.jpg": "info-longevity.jpg",
|
||||
"6d6025730c3d0b73c0052fa67b1bb111c8462a08.jpg": "feature-performance.jpg",
|
||||
"16e611de84f51bf5d01486a5e74ba0f8249850be.jpg": "feature-balance.jpg",
|
||||
"98f1f8d257dfeffbaf35507d033e5bb51e08895e.png": "feature-longevity.png",
|
||||
"28e8157dc88a42cffdbcd028e4cb02a6eb3de337.png": "quote-bg.png",
|
||||
"85808a0c9aeb461c70c059b013eab47faa4fc4f1.png": "training-one-to-one.png",
|
||||
"bb632b8da5e9e4d4b05207f04a8883163caac53a.png": "training-small-groups.png",
|
||||
"91aa82b5dedee8ad56c6db249c9947c7d6d9c89a.png": "training-performance-class.png",
|
||||
"86c1363b22a8fb3bb7b2bf044e52856d474d927e.jpg": "training-fit-remote.jpg",
|
||||
"0b6cf69438af21bbca934b0b27995ac4fa967451.png": "team-donata.png",
|
||||
"1cc33c41368d47c30d949e991c5a442ff51d1a91.png": "team-nicola.png",
|
||||
"a75ca105e362e3c3bc5cfe0bfc7c39e69645660d.png": "team-eleonora.png",
|
||||
"e3c84f9989df8b160d185bd40b774e460a4f6896.png": "team-antonello.png",
|
||||
"88cfcd5fd321b5ca6e4833e3980b792a95090545.jpg": "method-step-1.jpg",
|
||||
"d21671bb08f4f2eadde09f91a46b568788a209d7.jpg": "method-step-2.jpg",
|
||||
"a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg": "method-step-3.jpg",
|
||||
"e50b6f7818b3742ddba0f4ad4a162a8aafc505e0.jpg": "method-step-4.jpg",
|
||||
"bee22f37bba6f18d3defcd47762375e1c8ddd0f5.png": "partner-edenred.png",
|
||||
"cf079250ea1db7f0055b76af21e04b1ed1870770.png": "partner-welbee.png",
|
||||
"872c3679df24bd0d3fdca7c4cb9a24d1836309a0.png": "partner-howden.png",
|
||||
"014a7708ad61b12c5765ad4a60539d2fa325f9aa.png": "partner-aon.png",
|
||||
"8ffc5f7a2428b0d0eabd09f676ab98d3e5644f0d.png": "about-exterior.png",
|
||||
"32ecd00a20321e58c700fbaf739e53ac3bf03876.png": "about-team-group.png",
|
||||
"d4608b102eb67ac067e6585250b3c37963db1d34.png": "about-training-group.png",
|
||||
"dafcc19362e3a76dd20e9a656499a8068a3a9fca.png": "about-trainer-1.png",
|
||||
"9c50f1d3b3a0477e80e03c0849d3d4687610b461.png": "about-trainer-2.png",
|
||||
"c51a49b9be7f1b1a31de6b2f5261c8c5b5fdab52.png": "about-trainer-3.png",
|
||||
"3341a11b7153349a23c75b1fa2a9a62d48953330.png": "about-trainer-4.png"
|
||||
},
|
||||
"_loghi": "logo-dark.png e logo-light.png estratti da extract-logos.sh (FigmaDoc/Homepage.pdf), non da ../Dati"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const [, , username, password, role = 'admin'] = process.argv;
|
||||
if (!username || !password) {
|
||||
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!['admin', 'superuser', 'user'].includes(role)) {
|
||||
console.error('Ruolo non valido. Ammessi: admin, superuser, 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,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
);`);
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role`
|
||||
).run(username, bcrypt.hashSync(password, 12), role);
|
||||
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/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/"
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# extract-logos.sh — estrae loghi insanitylab dal PDF Figma Homepage
|
||||
# Pipeline: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
|
||||
# Idempotente: stessi file ad ogni esecuzione, coordinate fisse.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PDF="$ROOT/../FigmaDoc/Homepage.pdf"
|
||||
OUT="$ROOT/public/img"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
if [[ ! -f "$PDF" ]]; then
|
||||
echo "ERRORE: PDF non trovato: $PDF" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
echo "Estrazione loghi da $PDF..."
|
||||
|
||||
# ── Logo DARK (header, su fondo bianco) ─────────────────────────────────────
|
||||
# Zona: x=104, y=41, W=895, H=250 px a 300dpi (pt: x≈25, y≈10, W≈215, H≈60)
|
||||
pdftoppm -png -r 300 -f 1 -l 1 \
|
||||
-x 104 -y 41 -W 895 -H 250 \
|
||||
"$PDF" "$WORK/hdr"
|
||||
|
||||
convert "$WORK/hdr-1.png" \
|
||||
-fuzz 8% -transparent white \
|
||||
-trim +repage \
|
||||
-resize 600x \
|
||||
-strip \
|
||||
"$OUT/logo-dark.png"
|
||||
|
||||
# ── Logo LIGHT (footer, su fondo scuro #3A2929) ─────────────────────────────
|
||||
# Zona: x=0, y=35375, W=1800, H=833 px a 300dpi (pt: x≈0, y≈8490, W≈432, H≈200)
|
||||
# poi crop interno: x=130, y=60, W=850, H=660 per includere solo icona+testo
|
||||
pdftoppm -png -r 300 -f 1 -l 1 \
|
||||
-x 0 -y 35375 -W 1800 -H 833 \
|
||||
"$PDF" "$WORK/ftr"
|
||||
|
||||
convert "$WORK/ftr-1.png" \
|
||||
-crop 850x660+130+60 +repage \
|
||||
-fuzz 12% -transparent 'srgb(58,41,41)' \
|
||||
-trim +repage \
|
||||
-resize 600x \
|
||||
-strip \
|
||||
"$OUT/logo-light.png"
|
||||
|
||||
echo "Logo dark: $OUT/logo-dark.png $(identify -format '%wx%h' "$OUT/logo-dark.png")"
|
||||
echo "Logo light: $OUT/logo-light.png $(identify -format '%wx%h' "$OUT/logo-light.png")"
|
||||
echo "Done."
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Passo 3 della pipeline asset (dopo apply-asset-map.mjs).
|
||||
# Converte PNG fotografici in src/assets/img/ → JPG q83 e ridimensiona a ≤1400x1600px.
|
||||
# I PNG partner-* (loghi con trasparenza) restano PNG.
|
||||
# Tutti i JPG esistenti vengono ri-codificati a q83 con vincolo 1400x1600>
|
||||
# (larghezza ≤1400 px E altezza ≤1600 px, aspect ratio preservato).
|
||||
# Idempotente se abbinato a apply-asset-map.mjs (che ripristina i sorgenti originali).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
IMG_DIR="$ROOT/src/assets/img"
|
||||
|
||||
cd "$IMG_DIR"
|
||||
|
||||
# --- PNG fotografici → JPG q83 + resize ≤1400x1600 ---
|
||||
shopt -s nullglob
|
||||
declare -A just_converted
|
||||
converted=0
|
||||
for png in *.png; do
|
||||
[[ "$png" == partner-* ]] && continue
|
||||
jpg="${png%.png}.jpg"
|
||||
convert "$png" -quality 83 -resize '1400x1600>' "$jpg"
|
||||
rm "$png"
|
||||
echo " PNG→JPG: $png → $jpg"
|
||||
just_converted["$jpg"]=1
|
||||
(( converted++ )) || true
|
||||
done
|
||||
|
||||
# --- Ri-codifica JPG esistenti a q83 + vincolo 1400x1600 ---
|
||||
# Skip file appena convertiti (già a q83), elabora tutti gli altri.
|
||||
reencoded=0
|
||||
for jpg in *.jpg; do
|
||||
[[ -n "${just_converted[$jpg]+x}" ]] && continue
|
||||
mogrify -quality 83 -resize '1400x1600>' "$jpg"
|
||||
echo " Ricodificato: $jpg"
|
||||
(( reencoded++ )) || true
|
||||
done
|
||||
|
||||
echo "Ottimizzazione completata: ${converted} PNG→JPG, ${reencoded} JPG ricodificati (q83, ≤1400x1600)."
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/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 /services /services/performance-class /services/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
|
||||
|
||||
# Contenuti taggati
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/sitemap.xml)
|
||||
echo "$code /sitemap.xml"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
locs=$(curl -s http://localhost:4399/sitemap.xml | grep -c '<loc>')
|
||||
echo "$locs <loc> in sitemap (atteso >= 12)"
|
||||
[ "$locs" -ge 12 ] || fail=1
|
||||
tags=$(curl -s http://localhost:4399/ | grep -c 'data-tag=')
|
||||
echo "$tags righe con data-tag= in home (atteso >= 1)"
|
||||
[ "$tags" -ge 1 ] || fail=1
|
||||
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
|
||||
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
|
||||
[ "$badges" = "0" ] || fail=1
|
||||
|
||||
# /login pubblico raggiungibile
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
|
||||
echo "$code /login"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
|
||||
# Header mostra "Accedi" da anonimo
|
||||
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
|
||||
|
||||
# Nessun badge tag-badge da anonimo sulla home semplice
|
||||
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
|
||||
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
|
||||
[ "$badges2" = "0" ] || fail=1
|
||||
|
||||
# Rotta gestione utenti protetta (redirect al login da anonimo)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
|
||||
echo "$code /admin/users (atteso 302)"
|
||||
[ "$code" = "302" ] || fail=1
|
||||
|
||||
echo "smoke ruoli/login/inline OK"
|
||||
exit $fail
|
||||
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,55 @@
|
||||
---
|
||||
import { agenda, disciplines, days, slots } from '../data/agenda';
|
||||
import T from './content/T.astro';
|
||||
---
|
||||
<section class="section section--dark agenda">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
|
||||
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
|
||||
{disciplines.map((d, i) => (
|
||||
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
|
||||
))}
|
||||
</div>
|
||||
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
|
||||
<div class="agenda__corner"></div>
|
||||
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
|
||||
{slots.map((slot, si) => (
|
||||
<>
|
||||
<T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
|
||||
{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>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
import { t } from '../lib/content';
|
||||
import T from './content/T.astro';
|
||||
interface Props { variant?: 'full' | 'footer' }
|
||||
const { variant = 'full' } = Astro.props;
|
||||
---
|
||||
<form
|
||||
class:list={['cform', `cform--${variant}`]}
|
||||
method="post"
|
||||
action="/api/contact"
|
||||
novalidate
|
||||
data-msg-success={t('form.msg.success')}
|
||||
data-msg-error-generic={t('form.msg.error-generic')}
|
||||
data-msg-error-network={t('form.msg.error-network')}
|
||||
>
|
||||
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
|
||||
<input class="field" name="lastName" placeholder={t('form.field.last-name')} required maxlength="100" />
|
||||
<input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
|
||||
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
|
||||
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
|
||||
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
|
||||
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
|
||||
<p class="form-msg" hidden></p>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.cform__privacy { font-size: .74rem; opacity: .8; margin: 8px 0 0; line-height: 1.5; }
|
||||
.cform__privacy a { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
<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 ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
|
||||
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
|
||||
if (res.ok) form.reset();
|
||||
} catch {
|
||||
msg.textContent = form.dataset.msgErrorNetwork!;
|
||||
msg.className = 'form-msg form-msg--err';
|
||||
}
|
||||
msg.hidden = false;
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import { t, contentImageUrl } from '../lib/content';
|
||||
import T from './content/T.astro';
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
<footer class="ftr">
|
||||
<div class="container ftr__grid">
|
||||
<div>
|
||||
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
|
||||
<T tag="footer.tagline" as="p" class="ftr__tag" />
|
||||
<T tag="footer.about" as="p" />
|
||||
<p>{t('global.contact.address')}, {t('global.contact.city-line')}<br />{t('global.contact.phone')}<br /><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
|
||||
<p class="ftr__hours">{t('global.hours.1')}</p>
|
||||
<p class="ftr__hours">{t('global.hours.2')}</p>
|
||||
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
|
||||
</div>
|
||||
<nav aria-label="training">
|
||||
<T tag="footer.col.training-title" as="h4" />
|
||||
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
|
||||
</nav>
|
||||
<nav aria-label="servizi">
|
||||
<T tag="footer.col.services-title" as="h4" />
|
||||
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="ftr__bar">
|
||||
<div class="container ftr__bar-in">
|
||||
<p>© {year} <T tag="footer.copyright" as="span" /> <span class="ftr__sep">·</span> <a href="/privacy">Privacy Policy</a> <span class="ftr__sep">·</span> <a href="/cookie-policy">Cookie Policy</a></p>
|
||||
<p>{t('footer.social-label')} {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.6fr 1fr 1fr; gap: 40px; padding-block: 26px; text-align: center; align-items: start; }
|
||||
/* Il logo è display:block (reset globale): lo centro nella colonna */
|
||||
.ftr__grid img { margin-inline: auto; }
|
||||
/* Training e Servizi allineati alla riga "Promuoviamo il movimento..." (logo+tagline ~110px) */
|
||||
.ftr__grid nav { padding-top: 110px; }
|
||||
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
|
||||
.ftr__hours { margin: 0; }
|
||||
.ftr__legal { margin-top: 14px; font-size: .78rem; opacity: .8; }
|
||||
.ftr__bar-in .ftr__sep { margin: 0 4px; opacity: .5; }
|
||||
.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; } .ftr__grid nav { padding-top: 0; } }
|
||||
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import { t, contentImageUrl } from '../lib/content';
|
||||
interface Props {
|
||||
user: { username: string; role: string } | null;
|
||||
showTags: boolean;
|
||||
canTags: boolean;
|
||||
}
|
||||
const { user, canTags, showTags } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const loginHref = `/login?next=${encodeURIComponent(path)}`;
|
||||
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
||||
---
|
||||
<header class="hdr" id="site-header">
|
||||
<div class="container hdr__in">
|
||||
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
|
||||
<nav class="hdr__nav" id="site-nav" aria-label="principale">
|
||||
{site.navigation.map((item, i) => (
|
||||
(!('adminOnly' in item && item.adminOnly) || user?.role === 'admin') && (
|
||||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
<div class="hdr__user">
|
||||
{user ? (
|
||||
<details class="umenu">
|
||||
<summary class="umenu__btn">{user.username} ▾</summary>
|
||||
<div class="umenu__panel">
|
||||
<a href={areaHref}>Area riservata</a>
|
||||
{canTags && (
|
||||
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
|
||||
)}
|
||||
<a href="/admin/logout">Esci</a>
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<a class="hdr__login" href={loginHref}>Accedi</a>
|
||||
)}
|
||||
</div>
|
||||
<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); transition: color .3s ease; }
|
||||
.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; }
|
||||
}
|
||||
.hdr__user { display: flex; align-items: center; }
|
||||
.hdr__login { 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__login:hover { color: var(--c-accent-dark); }
|
||||
.umenu { position: relative; }
|
||||
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
|
||||
.umenu__btn::-webkit-details-marker { display: none; }
|
||||
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
|
||||
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
|
||||
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
|
||||
</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));
|
||||
});
|
||||
|
||||
const tagsBtn = document.getElementById('toggle-tags');
|
||||
if (tagsBtn) {
|
||||
const on = tagsBtn.dataset.on === '1';
|
||||
tagsBtn.addEventListener('click', async () => {
|
||||
const res = await fetch('/api/admin/showtags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ on: !on }),
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Operazione non riuscita.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
|
||||
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
|
||||
const { title, eyebrow, bgImage } = Astro.props;
|
||||
const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
|
||||
---
|
||||
<section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
|
||||
<div class="container" data-reveal>
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Banner scuro come nel Figma: ~460px, titolo bianco maiuscolo ~44px */
|
||||
.phero { background: var(--c-dark); min-height: 380px; display: flex; align-items: center; }
|
||||
.phero .container { width: 100%; }
|
||||
.phero h1 { text-transform: uppercase; font-weight: 500; color: #fff; font-size: clamp(2.2rem, 4vw, 2.75rem); letter-spacing: .04em; margin: 0; }
|
||||
.phero--photo { position: relative; background-size: cover; background-position: center; }
|
||||
.phero--photo::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.6); }
|
||||
.phero--photo .container { position: relative; }
|
||||
@media (max-width: 767px) { .phero { min-height: 240px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import T from '../content/T.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const stages = [1, 2, 3];
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="about.storylab.title" as="h2" />
|
||||
<T tag="about.storylab.body" as="p" />
|
||||
</div>
|
||||
<div class="tl">
|
||||
<T tag="about.timeline.label" as="h3" class="tl__label" />
|
||||
<T tag="about.timeline.open-text" as="p" />
|
||||
{stages.map((n) => (
|
||||
<details class="tl__stage">
|
||||
<summary>+ {t(`about.timeline.stage${n}.label`)}</summary>
|
||||
<T tag={`about.timeline.stage${n}.text`} as="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>
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { resolveContent } from '../../lib/content';
|
||||
interface Props { tag: string; as?: string; class?: string }
|
||||
const { tag, as = 'span', class: cls } = Astro.props;
|
||||
const Tag = as;
|
||||
const c = resolveContent(tag);
|
||||
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
|
||||
---
|
||||
{c.type === 'html'
|
||||
? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
|
||||
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { resolveContent } from '../../lib/content';
|
||||
interface Props {
|
||||
tag: string; src: ImageMetadata; alt: string;
|
||||
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
|
||||
}
|
||||
const { tag, src, alt, class: cls, ...rest } = Astro.props;
|
||||
const c = resolveContent(tag);
|
||||
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
|
||||
const guid = c.guid || undefined;
|
||||
---
|
||||
{override
|
||||
? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
|
||||
: <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import featPerformance from '../../assets/img/feature-performance.jpg';
|
||||
import featBalance from '../../assets/img/feature-balance.jpg';
|
||||
import featLongevity from '../../assets/img/feature-longevity.jpg';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const blocks = [
|
||||
{ key: 'performance', image: featPerformance, reverse: false },
|
||||
{ key: 'balance', image: featBalance, reverse: true },
|
||||
{ key: 'longevity', image: featLongevity, reverse: false },
|
||||
];
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
{blocks.map((b) => (
|
||||
<div class:list={['feat', { 'feat--reverse': b.reverse }]}>
|
||||
<TImg tag={`home.feature.${b.key}.image`} src={b.image} alt={t(`home.feature.${b.key}.title`)} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
|
||||
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
|
||||
<T tag={`home.feature.${b.key}.title`} as="h2" />
|
||||
<T tag={`home.feature.${b.key}.body`} as="p" />
|
||||
<a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</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>
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
// Foto hero rimossa ma conservata su disco per riuso futuro: assets/img/hero-1.png
|
||||
import T from '../content/T.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const slides = [
|
||||
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'] },
|
||||
];
|
||||
---
|
||||
<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.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
|
||||
<a class="btn" href="/services">{t('home.hero.cta')}</a>
|
||||
</div>
|
||||
</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; }
|
||||
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
|
||||
testo sovrapposto sulla sinistra. */
|
||||
.hero__in { position: relative; display: flex; align-items: center; min-height: 0; padding-block: 90px; }
|
||||
/* Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
|
||||
di questo componente: si ancora a .hero__text (che il cid ce l'ha) + :global. */
|
||||
.hero__text h1 :global(.hero__line) { display: block; }
|
||||
.hero__text { position: relative; z-index: 1; max-width: 640px; }
|
||||
.hero__text h1 { font-weight: 500; }
|
||||
.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 { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
|
||||
.hero__text { max-width: none; }
|
||||
}
|
||||
|
||||
/* Ingresso al load stile Slider Revolution del template */
|
||||
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line) { opacity: 0; animation: hero-up .8s ease forwards; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(1)) { animation-delay: .15s; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(2)) { animation-delay: .3s; }
|
||||
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(3)) { animation-delay: .45s; }
|
||||
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn { opacity: 1; animation: none; }
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import { getImage } from 'astro:assets';
|
||||
import infoPerformance from '../../assets/img/info-performance.jpg';
|
||||
import infoBalance from '../../assets/img/info-balance.jpg';
|
||||
import infoLongevity from '../../assets/img/info-longevity.jpg';
|
||||
import T from '../content/T.astro';
|
||||
import { contentImageUrl } from '../../lib/content';
|
||||
// getImage garantisce l'emissione dell'asset anche quando il file è byte-identico
|
||||
// ad altri import (Astro dedupa e non emette l'originale usato solo come background).
|
||||
const cols = await Promise.all([
|
||||
{ titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
|
||||
{ titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
|
||||
{ titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
|
||||
].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
|
||||
---
|
||||
<section class="info" aria-label="pilastri">
|
||||
{cols.map((c, i) => (
|
||||
<a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
|
||||
<T tag={c.titleTag} as="span" class="info__title" />
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
/* Proporzioni Figma: colonna 413x308 su frame 1209 — l'altezza scala con la larghezza */
|
||||
.info__col { position: relative; aspect-ratio: 413 / 308; 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; } .info__col { aspect-ratio: auto; } }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { methodSteps } from '../../data/method';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
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" data-reveal>
|
||||
<T tag="home.method.title" as="h2" />
|
||||
<T tag="method.intro" as="p" />
|
||||
</div>
|
||||
<T tag="method.extra-note" as="p" class="method__note" />
|
||||
<div class="method__row">
|
||||
{methodSteps.map((s, i) => (
|
||||
<div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
|
||||
<div class="method__circle">
|
||||
{imgOf(s.image) && <TImg tag={`method.step.${s.n}.image`} src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||||
<span>{s.n}</span>
|
||||
</div>
|
||||
<T tag={`method.step.${s.n}.title`} as="h3" />
|
||||
<T tag={`method.step.${s.n}.text`} as="p" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="method__cta" data-reveal>
|
||||
<a class="btn btn--dark" href="/contact"><T tag="home.method.cta" as="span" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
|
||||
.method__cta { text-align: center; margin-top: 48px; }
|
||||
.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; }
|
||||
/* :global(img) perché gli <img> di override resi da <TImg> non ereditano il cid
|
||||
del componente: senza questo la regola scoped non li raggiungerebbe. */
|
||||
.method__circle :global(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>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { partners } from '../../data/partners';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
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" data-reveal="fade">
|
||||
{partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.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>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import { getImage } from 'astro:assets';
|
||||
import quoteBgSrc from '../../assets/img/quote-bg.jpg';
|
||||
import { t, contentImageUrl } from '../../lib/content';
|
||||
const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
|
||||
---
|
||||
<section class="quote" data-parallax>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
|
||||
<div class="quote__in">
|
||||
<p class="quote__mark">“</p>
|
||||
<blockquote>
|
||||
<div class="quote__marquee">
|
||||
<p class="quote__track">
|
||||
<span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
<span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
</p>
|
||||
</div>
|
||||
<cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.quote { position: relative; overflow: hidden; text-align: center; }
|
||||
/* Sfondo su div dedicato, esteso oltre i bordi per il margine di corsa del parallax */
|
||||
.quote__bg { position: absolute; inset: -120px 0; background-size: cover; background-position: center; transform: translateY(var(--parallax-y, 0)); will-change: transform; }
|
||||
.quote__bg::after { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
|
||||
.quote__in { position: relative; padding-block: 110px; }
|
||||
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
|
||||
.quote blockquote { margin: 0; }
|
||||
/* Marquee: testo a scorrimento infinito come il ticker del template */
|
||||
.quote__marquee { overflow: hidden; white-space: nowrap; }
|
||||
.quote__track { display: inline-flex; margin: 0; animation: quote-scroll 30s linear infinite; will-change: transform; }
|
||||
.quote__marquee:hover .quote__track { animation-play-state: paused; }
|
||||
.quote__seg { padding-right: 4em; color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
|
||||
@keyframes quote-scroll { to { transform: translateX(-50%); } }
|
||||
.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; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quote__track { animation: none; white-space: normal; display: block; max-width: 820px; margin-inline: auto; padding-inline: 20px; }
|
||||
.quote__seg { padding-right: 0; }
|
||||
.quote__seg[aria-hidden] { display: none; }
|
||||
.quote__bg { transform: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
import { services } from '../../data/services';
|
||||
import { serviceIcons as icons } from '../../data/service-icons';
|
||||
import T from '../content/T.astro';
|
||||
|
||||
// Stessa convenzione di /services: le voci con grid:false (Small Group, Rehab,
|
||||
// Holistic Class, Reformer Class) sono approfondimenti dei riquadri Training e
|
||||
// non compaiono nella griglia Servizi.
|
||||
const gridServices = services.filter((p) => p.grid !== false);
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal>
|
||||
<T tag="home.services.title" as="h2" />
|
||||
<T tag="home.services.body" as="p" />
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{gridServices.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
<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>
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="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; transition: transform .3s ease; }
|
||||
.pgrid__item:hover { transform: translateY(-4px); }
|
||||
.pgrid__item:hover h3 { color: var(--c-accent-dark); }
|
||||
.pgrid__item h3 { transition: color .3s ease; }
|
||||
.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>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { team, homeTeam } from '../../data/team';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
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" data-reveal>
|
||||
<T tag="home.team.title" as="h2" />
|
||||
<a class="btn" href="/about"><T tag="home.team.cta" as="span" /></a>
|
||||
</div>
|
||||
<div class="tteam__grid">
|
||||
{members.map((m, i) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
|
||||
<TImg tag={`team.${slug}.image`} src={imgOf(m.image!)!} alt={t(`team.${slug}.name`)} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<T tag={`team.${slug}.short-name`} as="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; overflow: hidden; }
|
||||
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; transition: transform .5s ease; }
|
||||
.tteam__card:hover img { transform: scale(1.06); }
|
||||
.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>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { trainings } from '../../data/trainings';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
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 = ['taupe', 'tan', 'dark', 'charcoal'];
|
||||
// riga 1 (training 0-1): card, foto | riga 2 (training 2-3): foto, card
|
||||
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
||||
---
|
||||
<section class="tcards">
|
||||
{trainings.map((tr, i) => {
|
||||
const img = imgOf(tr.image);
|
||||
const card = (
|
||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
||||
<T tag={`training.${tr.id}.title`} as="h3" />
|
||||
<T tag={`training.${tr.id}.caption`} as="p" />
|
||||
<a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
|
||||
</div>
|
||||
);
|
||||
const photo = img ? (
|
||||
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
|
||||
<TImg tag={`training.${tr.id}.image`} src={img} alt={t(`training.${tr.id}.title`)} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
</div>
|
||||
) : null;
|
||||
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
|
||||
})}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; aspect-ratio: 1; }
|
||||
.tcards__cell--taupe { background: #6b5d5b; }
|
||||
.tcards__cell--dark { background: var(--c-dark); }
|
||||
.tcards__cell--charcoal { background: #27252a; }
|
||||
.tcards__cell--taupe h3, .tcards__cell--taupe p,
|
||||
.tcards__cell--dark h3, .tcards__cell--dark p,
|
||||
.tcards__cell--charcoal h3, .tcards__cell--charcoal 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__photo { overflow: hidden; }
|
||||
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
|
||||
.tcards__photo:hover .tcards__img { transform: scale(1.06); }
|
||||
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
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" },
|
||||
];
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { StyleGroup } from '../lib/style-presets';
|
||||
import { site } from './site';
|
||||
import { trainings } from './trainings';
|
||||
import { team } from './team';
|
||||
import { methodIntro, methodExtraNote, methodSteps } from './method';
|
||||
import { partners } from './partners';
|
||||
import { disciplines, days, slots } from './agenda';
|
||||
import { services } from './services';
|
||||
import { performanceClassPricing } from './pricing';
|
||||
|
||||
export type ContentType = 'text' | 'html' | 'image';
|
||||
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
|
||||
|
||||
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style', 'align']): SeedEntry =>
|
||||
({ tag, type: 'text', value, styleOptions });
|
||||
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'align']): SeedEntry =>
|
||||
({ tag, type: 'html', value, styleOptions });
|
||||
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
|
||||
|
||||
export const contentSeed: SeedEntry[] = [
|
||||
// --- Globali (site.ts) ---
|
||||
T('global.contact.phone', site.phone, []),
|
||||
T('global.contact.email', site.email, []),
|
||||
T('global.contact.address', site.address, []),
|
||||
T('global.contact.city-line', site.cityLine, []),
|
||||
T('global.contact.map-query', site.mapQuery, []),
|
||||
T('global.whatsapp', '351 753 5977', []),
|
||||
T('global.hours.1', site.hours[0], []),
|
||||
T('global.hours.2', site.hours[1], []),
|
||||
T('global.nav.1.label', site.navigation[0].label, []),
|
||||
T('global.nav.2.label', site.navigation[1].label, []),
|
||||
T('global.nav.3.label', site.navigation[2].label, []),
|
||||
T('global.nav.4.label', site.navigation[3].label, []),
|
||||
T('global.nav.5.label', site.navigation[4].label, []),
|
||||
T('global.nav.6.label', site.navigation[5].label, []),
|
||||
T('global.nav.7.label', site.navigation[6].label, []),
|
||||
|
||||
// --- Header ---
|
||||
T('header.logo.alt', 'InsanityLab', []),
|
||||
I('header.logo.image'),
|
||||
|
||||
// --- Footer ---
|
||||
I('footer.logo.image'),
|
||||
H('footer.tagline', 'PERFORMANCE. BALANCE. LONGEVITY.'),
|
||||
T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'),
|
||||
T('footer.col.training-title', 'Training'),
|
||||
T('footer.col.services-title', 'Servizi'),
|
||||
T('footer.col.contact-title', 'Contact'),
|
||||
H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'),
|
||||
T('footer.social-label', 'Seguici', []),
|
||||
T('footer.training.1.label', site.footerTraining[0].label, []),
|
||||
T('footer.training.2.label', site.footerTraining[1].label, []),
|
||||
T('footer.training.3.label', site.footerTraining[2].label, []),
|
||||
T('footer.training.4.label', site.footerTraining[3].label, []),
|
||||
T('footer.training.5.label', site.footerTraining[4].label, []),
|
||||
T('footer.training.6.label', site.footerTraining[5].label, []),
|
||||
T('footer.services.1.label', site.footerServices[0].label, []),
|
||||
T('footer.services.2.label', site.footerServices[1].label, []),
|
||||
T('footer.services.3.label', site.footerServices[2].label, []),
|
||||
T('footer.services.4.label', site.footerServices[3].label, []),
|
||||
T('footer.services.5.label', site.footerServices[4].label, []),
|
||||
T('footer.services.6.label', site.footerServices[5].label, []),
|
||||
|
||||
// --- Contact form ---
|
||||
T('form.field.first-name', 'Nome', []),
|
||||
T('form.field.last-name', 'Cognome', []),
|
||||
T('form.field.phone', 'Telefono', []),
|
||||
T('form.field.email', 'E-mail', []),
|
||||
T('form.field.message', 'Messaggio', []),
|
||||
T('form.submit', 'Invia messaggio'),
|
||||
T('form.msg.success', 'Messaggio inviato, ti ricontatteremo al più presto.', []),
|
||||
T('form.msg.error-generic', 'Errore di invio, riprova più tardi.', []),
|
||||
T('form.msg.error-network', 'Errore di rete, riprova più tardi.', []),
|
||||
|
||||
// --- Home: meta, training heading ---
|
||||
T('home.meta.title', 'Performance. Balance. Longevity.', []),
|
||||
T('home.training.title', 'Training'),
|
||||
T('home.training.subtitle', 'Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.'),
|
||||
|
||||
// --- Home: hero ---
|
||||
T('home.hero.title-line-1', 'PERFORMANCE.'),
|
||||
T('home.hero.title-line-2', 'BALANCE.'),
|
||||
T('home.hero.title-line-3', 'LONGEVITY.'),
|
||||
T('home.hero.cta', 'Scopri'),
|
||||
I('home.hero.image'),
|
||||
|
||||
// --- Home: info columns ---
|
||||
T('home.info.col1.title', 'PERFORMANCE.'),
|
||||
T('home.info.col2.title', 'BALANCE.'),
|
||||
T('home.info.col3.title', 'LONGEVITY.'),
|
||||
I('home.info.col1.image'),
|
||||
I('home.info.col2.image'),
|
||||
I('home.info.col3.image'),
|
||||
|
||||
// --- Home: feature blocks ---
|
||||
T('home.feature.performance.title', 'PERFORMANCE'),
|
||||
T('home.feature.performance.body', '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.'),
|
||||
T('home.feature.performance.cta', 'Scopri'),
|
||||
T('home.feature.balance.title', 'BALANCE'),
|
||||
T('home.feature.balance.body', '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.'),
|
||||
T('home.feature.balance.cta', 'Scopri'),
|
||||
T('home.feature.longevity.title', 'LONGEVITY'),
|
||||
T('home.feature.longevity.body', '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.'),
|
||||
T('home.feature.longevity.cta', 'Scopri'),
|
||||
I('home.feature.performance.image'),
|
||||
I('home.feature.balance.image'),
|
||||
I('home.feature.longevity.image'),
|
||||
|
||||
// --- Home: quote banner ---
|
||||
H('home.quote.text', '<strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.'),
|
||||
T('home.quote.cite', 'Insanitylab', []),
|
||||
I('home.quote.image'),
|
||||
|
||||
// --- Home: training cards (data-driven da trainings.ts) ---
|
||||
T('home.training-cards.link-label', 'Scopri', []),
|
||||
...trainings.flatMap((tr) => [
|
||||
T(`training.${tr.id}.title`, tr.title),
|
||||
T(`training.${tr.id}.caption`, tr.caption),
|
||||
T(`training.${tr.id}.description`, tr.description),
|
||||
T(`training.${tr.id}.cta-label`, tr.cta.label, []),
|
||||
I(`training.${tr.id}.image`),
|
||||
]),
|
||||
|
||||
// --- Home: servizi ---
|
||||
T('home.services.title', 'Servizi'),
|
||||
T('home.services.body', 'Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'),
|
||||
|
||||
// --- Home: team (data-driven da team.ts) ---
|
||||
H('home.team.title', 'Team<br />Trainer'),
|
||||
T('home.team.cta', 'Scopri team', []),
|
||||
...team.flatMap((m) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return [
|
||||
T(`team.${slug}.name`, m.name, []),
|
||||
T(`team.${slug}.short-name`, m.shortName),
|
||||
T(`team.${slug}.role`, m.role, []),
|
||||
T(`team.${slug}.bio`, m.bio),
|
||||
I(`team.${slug}.image`),
|
||||
I(`team.${slug}.image-about`),
|
||||
];
|
||||
}),
|
||||
|
||||
// --- Home: metodo (data-driven da method.ts) ---
|
||||
H('home.method.title', 'Il metodo<br />InsanityLab'),
|
||||
T('home.method.cta', 'Contattaci', []),
|
||||
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
|
||||
T('method.extra-note', methodExtraNote, []),
|
||||
...methodSteps.flatMap((s) => [
|
||||
T(`method.step.${s.n}.title`, s.title),
|
||||
T(`method.step.${s.n}.text`, s.text),
|
||||
I(`method.step.${s.n}.image`),
|
||||
]),
|
||||
|
||||
// --- Home: partner (data-driven da partners.ts) ---
|
||||
...partners.flatMap((p, i) => [
|
||||
T(`partner.${i + 1}.name`, p.name, []),
|
||||
I(`partner.${i + 1}.image`),
|
||||
]),
|
||||
|
||||
// --- Agenda (scope limitato: titolo, discipline, giorni, slot — celle escluse) ---
|
||||
T('agenda.title', 'Agenda'),
|
||||
...disciplines.map((d, i) => T(`agenda.discipline.${i + 1}`, d, [])),
|
||||
...days.map((d, i) => T(`agenda.day.${i + 1}`, d, [])),
|
||||
...slots.map((s, i) => T(`agenda.slot.${i + 1}`, s, [])),
|
||||
|
||||
// --- About: meta, hero ---
|
||||
T('about.meta.title', 'Insanitylab', []),
|
||||
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
|
||||
T('about.hero.title', 'Insanitylab', []),
|
||||
|
||||
// --- About: mission ---
|
||||
T('about.mission.title', 'Mission'),
|
||||
T('about.mission.body', '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.'),
|
||||
I('about.mission.image'),
|
||||
|
||||
// --- About: direzione + approccio ---
|
||||
T('about.direzione.title', 'Direzione'),
|
||||
T('about.direzione.body', '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.'),
|
||||
T('about.direzione.cta', 'Scopri'),
|
||||
T('about.approccio.title', 'Approccio'),
|
||||
T('about.approccio.body', '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.'),
|
||||
T('about.approccio.cta', 'Contattaci'),
|
||||
|
||||
// --- About: about ---
|
||||
T('about.about.title', 'About'),
|
||||
T('about.about.body', "InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."),
|
||||
I('about.about.image'),
|
||||
|
||||
// --- About: convenzioni ---
|
||||
T('about.convenzioni.title', 'Convenzioni'),
|
||||
T('about.convenzioni.body', "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."),
|
||||
T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'),
|
||||
T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'),
|
||||
T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."),
|
||||
T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
|
||||
I('about.convenzioni.image'),
|
||||
|
||||
// --- About: team ---
|
||||
T('about.team.title', 'Team'),
|
||||
T('about.team.body', 'Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.'),
|
||||
T('about.team.staff-intro', 'Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:'),
|
||||
|
||||
// --- About: metodo ---
|
||||
T('about.metodo.title', 'Metodo'),
|
||||
T('about.metodo.body', '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.'),
|
||||
T('about.metodo.closing', '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.'),
|
||||
I('about.metodo.image'),
|
||||
|
||||
// --- About: studios ---
|
||||
T('about.studios.title', 'In-Sanity Studios'),
|
||||
T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."),
|
||||
T('about.studios.body2', "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."),
|
||||
T('about.studios.body3', '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.'),
|
||||
I('about.studios.image'),
|
||||
|
||||
// --- About: vision ---
|
||||
T('about.vision.title', 'Vision'),
|
||||
T('about.vision.body1', "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."),
|
||||
T('about.vision.body2', "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à."),
|
||||
I('about.vision.image'),
|
||||
|
||||
// --- About: storylab + timeline (Timeline.astro) ---
|
||||
T('about.storylab.title', 'Storylab'),
|
||||
T('about.storylab.body', '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.'),
|
||||
T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
|
||||
T('about.timeline.open-text', "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à."),
|
||||
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
|
||||
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
|
||||
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
|
||||
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
// --- Services: lista (/services) ---
|
||||
T('services.meta.title', 'Servizi', []),
|
||||
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []),
|
||||
T('services.hero.title', 'Servizi', []),
|
||||
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
|
||||
T('services.card.more-label', 'Per saperne di più →', []),
|
||||
|
||||
// --- Services: dettaglio (data-driven da services.ts) ---
|
||||
...services.flatMap((p) => [
|
||||
T(`service.${p.slug}.title`, p.title),
|
||||
T(`service.${p.slug}.subtitle`, p.subtitle),
|
||||
T(`service.${p.slug}.excerpt`, p.excerpt),
|
||||
T(`service.${p.slug}.long-description`, p.longDescription),
|
||||
...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
|
||||
...p.faq.flatMap((item, i) => [
|
||||
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
|
||||
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
|
||||
]),
|
||||
I(`service.${p.slug}.image`),
|
||||
]),
|
||||
|
||||
// --- Services: dettaglio, template ---
|
||||
T('service.side.categories-label', 'Categorie'),
|
||||
T('service.side.category.1', 'Fitness', []),
|
||||
T('service.side.category.2', 'Salute', []),
|
||||
T('service.side.category.3', 'Stile di vita', []),
|
||||
T('service.side.category.4', 'Nutrizione', []),
|
||||
T('service.side.others-label', 'Altri servizi'),
|
||||
H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
|
||||
T('service.pricing.per', 'al mese, semestrale', []),
|
||||
T('service.pricing.entries-singular', 'ingresso settimanale', []),
|
||||
T('service.pricing.entries-plural', 'ingressi settimanali', []),
|
||||
T('service.pricing.buy-cta', 'Acquista', []),
|
||||
|
||||
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
|
||||
T('pricing.heading', performanceClassPricing.heading),
|
||||
T('pricing.intro', performanceClassPricing.intro),
|
||||
...performanceClassPricing.groups.flatMap((g, gi) => [
|
||||
T(`pricing.group.${gi + 1}.label`, g.label),
|
||||
...g.plans.flatMap((plan, pi) => [
|
||||
T(`pricing.group.${gi + 1}.plan.${pi + 1}.note`, plan.note, []),
|
||||
T(`pricing.group.${gi + 1}.plan.${pi + 1}.installments`, plan.installments, []),
|
||||
T(`pricing.group.${gi + 1}.plan.${pi + 1}.best`, plan.best, []),
|
||||
]),
|
||||
]),
|
||||
|
||||
// --- Training: meta, hero, intro, trial ---
|
||||
T('training.meta.title', 'Training', []),
|
||||
T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
|
||||
T('training.hero.title', 'training', []),
|
||||
T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'),
|
||||
|
||||
// --- Contact ---
|
||||
T('contact.meta.title', 'Contatti', []),
|
||||
T('contact.meta.description', 'Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.', []),
|
||||
T('contact.hero.title', 'Contattaci', []),
|
||||
I('contact.hero.image'),
|
||||
T('contact.map.button', 'Carica la mappa (Google Maps)', []),
|
||||
T('contact.map.note', 'La mappa viene caricata da Google solo dopo il tuo consenso.', []),
|
||||
T('contact.info.address-label', 'Indirizzo'),
|
||||
T('contact.info.phone-label', 'Telefono'),
|
||||
T('contact.info.email-label', 'E-mail'),
|
||||
T('contact.invite', '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.'),
|
||||
|
||||
// --- 404 ---
|
||||
T('notfound.meta.title', 'Pagina non trovata', []),
|
||||
T('notfound.eyebrow', 'Errore 404', []),
|
||||
T('notfound.title', 'Pagina non trovata'),
|
||||
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
|
||||
T('notfound.cta', 'Torna alla home', []),
|
||||
|
||||
// --- Promo ed eventi (promo-eventi.astro) ---
|
||||
// Titolo del banner PageHero (usato dalla variante promo-eventi2)
|
||||
T('promo.pagehero.title', 'Promo ed eventi'),
|
||||
// Hero
|
||||
T('promo.hero.badge', 'insanity.lab · bologna savena'),
|
||||
T('promo.hero.title1', 'Allenati con metodo.'),
|
||||
T('promo.hero.accent', 'Scegli come'),
|
||||
T('promo.hero.title2', 'vuoi farlo.'),
|
||||
H('promo.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
|
||||
T('promo.hero.cta', 'Le nostre proposte', []),
|
||||
// Launch
|
||||
T('promo.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
|
||||
T('promo.launch.title', 'Il nostro metodo nasce per durare.'),
|
||||
T('promo.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
|
||||
T('promo.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte."),
|
||||
// Perché 5
|
||||
T('promo.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
|
||||
// Label bottone acquisto (condivisa dalle 4 card)
|
||||
T('promo.buy-label', 'Acquista', []),
|
||||
// Card 1 — One to One
|
||||
T('promo.card.1.eyebrow', 'Personal training', []),
|
||||
T('promo.card.1.title', 'One to One'),
|
||||
T('promo.card.1.price', '163 €', []),
|
||||
T('promo.card.1.price-old', '300 €', []),
|
||||
T('promo.card.1.unit', '5 incontri', []),
|
||||
T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||
T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
||||
T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
|
||||
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||
// Card 2 — Small Group (in evidenza)
|
||||
T('promo.card.2.badge', 'Più scelto', []),
|
||||
T('promo.card.2.eyebrow', 'Personal training', []),
|
||||
T('promo.card.2.title', 'Small Group'),
|
||||
T('promo.card.2.price', '110 €', []),
|
||||
T('promo.card.2.price-old', '220 €', []),
|
||||
T('promo.card.2.unit', '5 incontri', []),
|
||||
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
|
||||
T('promo.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
|
||||
T('promo.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
|
||||
T('promo.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
|
||||
// Card 3 — Reformer
|
||||
T('promo.card.3.eyebrow', 'Pilates class', []),
|
||||
T('promo.card.3.title', 'Reformer'),
|
||||
T('promo.card.3.price', '100 €', []),
|
||||
T('promo.card.3.price-old', '140 €', []),
|
||||
T('promo.card.3.unit', '5 incontri', []),
|
||||
T('promo.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
|
||||
T('promo.card.3.feature.2', 'Lavoro a basso impatto sulle articolazioni'),
|
||||
T('promo.card.3.feature.3', 'Percorso breve, non lezioni isolate'),
|
||||
// Card 4 — Holistic class
|
||||
T('promo.card.4.eyebrow', 'Holistic class', []),
|
||||
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
|
||||
T('promo.card.4.price', '50 €', []),
|
||||
T('promo.card.4.price-old', '100 €', []),
|
||||
T('promo.card.4.unit', '5 incontri', []),
|
||||
T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
|
||||
T('promo.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
|
||||
T('promo.card.4.feature.3', 'Adatto a tutti i livelli'),
|
||||
// Nota validità
|
||||
T('promo.validity-note', 'Disponibile solo per l\'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
|
||||
// FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp
|
||||
T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
|
||||
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'),
|
||||
H('promo.faq.1.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi ci scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare le singole sessioni in base agli orari disponibili. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
|
||||
T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'),
|
||||
H('promo.faq.2.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi vai nella schermata Home, clicca il servizio che hai acquistato e seleziona autonomamente la class nel giorno e nell\'orario che desideri. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
|
||||
T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
|
||||
H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Small Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'),
|
||||
T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
|
||||
H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
|
||||
T('promo.faq.5.q', 'Il pagamento è sicuro?'),
|
||||
H('promo.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
|
||||
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
|
||||
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
|
||||
|
||||
];
|
||||
|
||||
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
|
||||
@@ -0,0 +1,9 @@
|
||||
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: "La base di partenza per definire le priorità del percorso di ciascun cliente.", extra: false, image: "method-step-1" },
|
||||
{ n: 2, title: "Check-up fisico", text: "Profilazione fisica per definire lo stato di salute iniziale del cliente.", extra: false, image: "method-step-2" },
|
||||
{ n: 3, title: "Insanity plan", text: "Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.", extra: true, image: "method-step-3" },
|
||||
{ n: 4, title: "Monitoraggio costante", text: "Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.", extra: true, image: "method-step-4" },
|
||||
];
|
||||