Compare commits
91 Commits
6caba6fee1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
uploads
|
||||
.env
|
||||
.git
|
||||
docs
|
||||
assets-src
|
||||
tests
|
||||
*.md
|
||||
@@ -1,8 +1,13 @@
|
||||
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
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
|
||||
@@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const [, , username, password] = process.argv;
|
||||
const [, , username, password, role = 'admin'] = process.argv;
|
||||
if (!username || !password) {
|
||||
console.error('Uso: npm run create-user -- <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';
|
||||
@@ -14,10 +18,11 @@ 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
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
);`);
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
|
||||
).run(username, bcrypt.hashSync(password, 12));
|
||||
`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,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 |
|
Before Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 202 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 |
|
Before Width: | Height: | Size: 458 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 |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 143 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 |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 192 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" />
|
||||
{variant === 'full' && <input class="field" name="lastName" placeholder={t('form.field.last-name')} maxlength="100" />}
|
||||
{variant === 'full' && <input class="field" name="phone" placeholder={t('form.field.phone')} 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,55 @@
|
||||
---
|
||||
import { site } from '../data/site';
|
||||
import ContactForm from './ContactForm.astro';
|
||||
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>
|
||||
<T tag="footer.col.contact-title" as="h4" />
|
||||
<ContactForm variant="footer" />
|
||||
</div>
|
||||
</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.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
|
||||
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
|
||||
.ftr__hours { margin: 0; }
|
||||
.ftr__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; } }
|
||||
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
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) => (
|
||||
<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,78 @@
|
||||
---
|
||||
import hero1 from '../../assets/img/hero-1.png';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const slides = [
|
||||
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'], image: hero1, alt: 'Atleti InsanityLab' },
|
||||
];
|
||||
---
|
||||
<section class="hero" aria-label="presentazione">
|
||||
{slides.map((s, i) => (
|
||||
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
|
||||
<div class="container hero__in">
|
||||
<div class="hero__text">
|
||||
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
|
||||
<a class="btn" href="/services">{t('home.hero.cta')}</a>
|
||||
</div>
|
||||
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button class="hero__arrow hero__arrow--prev" aria-label="precedente">‹</button>
|
||||
<button class="hero__arrow hero__arrow--next" aria-label="successiva">›</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero { position: relative; background:
|
||||
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
|
||||
.hero__slide { display: none; }
|
||||
.hero__slide.is-active { display: block; }
|
||||
/* 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: calc(100vh - var(--header-h)); }
|
||||
/* 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: 46%; }
|
||||
.hero__text h1 { font-weight: 500; }
|
||||
.hero__img { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
|
||||
.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; }
|
||||
.hero__img { position: static; max-width: 100%; height: auto; }
|
||||
}
|
||||
|
||||
/* Ingresso al load stile Slider Revolution del template */
|
||||
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes hero-side { from { opacity: 0; transform: translateX(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; }
|
||||
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn, :global(html.js) .hero__img { 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,304 @@
|
||||
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']): SeedEntry =>
|
||||
({ tag, type: 'text', value, styleOptions });
|
||||
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): 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.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, []),
|
||||
|
||||
// --- 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', []),
|
||||
|
||||
];
|
||||
|
||||
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" },
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
export const partners = [
|
||||
{ name: "Ticket Welfare Edenred", image: "partner-edenred" },
|
||||
{ name: "Welbee", image: "partner-welbee" },
|
||||
{ name: "AON", image: "partner-aon" },
|
||||
{ name: "Howden", image: "partner-howden" },
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
export const performanceClassPricing = {
|
||||
heading: "Acquista la tua performance class",
|
||||
intro: "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.",
|
||||
groups: [
|
||||
{
|
||||
label: "Programmi performance class semestrale",
|
||||
open: true,
|
||||
plans: [
|
||||
{ monthly: 130, entries: 1, note: "tot: 780€ · da 23€ lezione singola", installments: "2 rate da 390€ – inizio 60", best: "best price 720€" },
|
||||
{ monthly: 234, entries: 2, note: "tot: 1.400€ · da 23€ lezione singola", installments: "2 rate da 700€ – inizio 60", best: "best price 1.250€" },
|
||||
{ monthly: 300, entries: 3, note: "tot: 1.800€ · da 23€ lezione singola", installments: "2 rate da 900€ – inizio 60", best: "best price 1.650€" },
|
||||
],
|
||||
},
|
||||
{ label: "Programmi performance class trimestrale", open: false, plans: [] },
|
||||
{ label: "Programmi performance class mensile", open: false, plans: [] },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
|
||||
export const serviceIcons: Record<string, string> = {
|
||||
'one-to-one': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'small-group': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
|
||||
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
|
||||
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
|
||||
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
|
||||
// Servizi rinominati (modifiche 02): profilazione (grafico/analisi), osteopatia (mani)
|
||||
'profilazione': 'M4 20V6m5 14V4m5 16v-8m5 8V9M3 20h18',
|
||||
'osteopatia': 'M8 3v7a4 4 0 008 0V3M6 14c0 4 3 7 6 7s6-3 6-7',
|
||||
// Riquadri Training con pagina dettaglio
|
||||
'holistic-class': 'M12 3a3 3 0 110 6 3 3 0 010-6zm0 6v7m-4 3l4-3 4 3',
|
||||
'reformer-class': 'M3 8h18M3 16h18M6 8v8m12-8v8M9 12h6',
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
export interface Service {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
excerpt: string;
|
||||
longDescription: string;
|
||||
features: string[];
|
||||
faq: { q: string; a: string }[];
|
||||
hasPricing: boolean;
|
||||
/** false = pagina dettaglio esiste ma la voce non compare nella griglia /services
|
||||
* (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */
|
||||
grid?: boolean;
|
||||
}
|
||||
|
||||
export const services: Service[] = [
|
||||
{
|
||||
slug: "one-to-one",
|
||||
title: "One to One",
|
||||
subtitle: "Allenamento con un trainer dedicato",
|
||||
excerpt: "L’allenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.",
|
||||
longDescription: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati, con supporto costante, attenzione totale e allenamenti personalizzati.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Come si svolge la prima seduta?", a: "Con una consulenza conoscitiva iniziale: raccogliamo l’anamnesi, definiamo gli obiettivi e impostiamo il percorso più adatto." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul tuo livello, con progressioni graduali e supervisione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "small-group",
|
||||
title: "Small Group",
|
||||
subtitle: "Mini gruppi (max 4 persone) con trainer dedicato",
|
||||
excerpt: "Lo Small Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.",
|
||||
longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Quante persone ci sono in un Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." },
|
||||
{ q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." },
|
||||
{ q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
slug: "performance-class",
|
||||
title: "Performance Class",
|
||||
subtitle: "Gruppo max 8 persone",
|
||||
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
||||
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
||||
features: ["Allenamento ibrido", "Forza, ipertrofia, condizionamento metabolico e lavoro funzionale", "Sessioni da 50 minuti", "Fasi di bassa, media e alta intensità", "Programmazione mensile"],
|
||||
faq: [
|
||||
{ q: "Che cosa si intende per Performance Class?", a: "Un allenamento in piccoli gruppi (massimo 8 persone) guidato da trainer certificati, con programmazione progressiva mensile: ogni sessione ha un posto preciso all’interno di un piano più ampio." },
|
||||
{ q: "Che tipo di allenamento viene svolto?", a: "Un allenamento ibrido che alterna forza, ipertrofia, condizionamento metabolico e lavoro funzionale. Le sessioni durano 50 minuti e prevedono fasi di bassa, media e alta intensità, calibrate sulla programmazione mensile." },
|
||||
{ q: "A chi è consigliato questo servizio?", a: "A chi vuole allenarsi con qualità e continuità in un contesto di gruppo motivante, mantenendo una programmazione seria e misurabile." },
|
||||
{ q: "Qual è la differenza rispetto a una class tradizionale?", a: "La programmazione: non è una lezione isolata ma parte di un piano mensile progressivo, con carichi e intensità calibrati." },
|
||||
{ q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." },
|
||||
{ q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." },
|
||||
],
|
||||
hasPricing: true,
|
||||
},
|
||||
{
|
||||
slug: "coaching",
|
||||
title: "Coaching",
|
||||
subtitle: "Programma di allenamento e monitoraggio",
|
||||
excerpt: "Il coaching InsanityLab è un percorso personalizzato che, con programma su misura e supporto costante, guida la persona verso i propri obiettivi sviluppando autonomia e continuità nell’allenamento.",
|
||||
longDescription: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (formula Fit Remote).",
|
||||
features: ["Programma su misura", "Supporto e monitoraggio costanti", "Ovunque ti alleni (Fit Remote)", "Sviluppo di autonomia"],
|
||||
faq: [
|
||||
{ q: "Come funziona il monitoraggio?", a: "Il trainer segue i tuoi progressi con check periodici e adatta il programma in base ai risultati." },
|
||||
{ q: "Serve attrezzatura?", a: "Il programma viene costruito sull’attrezzatura che hai a disposizione, in palestra o a casa." },
|
||||
{ q: "È adatto se viaggio spesso?", a: "Sì: la formula Fit Remote è pensata per chi si allena in luoghi diversi." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
// NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training).
|
||||
// Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente.
|
||||
slug: "profilazione",
|
||||
title: "Profilazione",
|
||||
subtitle: "Check-up e valutazione fisica",
|
||||
excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.",
|
||||
longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.",
|
||||
features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"],
|
||||
faq: [
|
||||
{ q: "Quando si fa la profilazione?", a: "All’inizio del percorso e poi periodicamente, per misurare i progressi e aggiornare il programma." },
|
||||
{ q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
// NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training).
|
||||
slug: "osteopatia",
|
||||
title: "Osteopatia",
|
||||
subtitle: "Approccio manuale globale non invasivo",
|
||||
excerpt: "L’osteopatia è una delle colonne portanti dell’approccio integrato di InsanityLab. Non è un servizio aggiunto: è parte della visione.",
|
||||
longDescription: "L’osteopatia è indicata sia in modo preventivo, per chi vuole mantenere il proprio equilibrio corporeo, sia in modo curativo, per chi sta affrontando un problema specifico. È particolarmente utile in sinergia con l’allenamento, perché permette di lavorare su compensi e rigidità che altrimenti limiterebbero i progressi. Nel nostro approccio rientrano anche l’osteopatia neonatale e pediatrica.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "L’osteopatia è preventiva o curativa?", a: "Entrambe: è utile sia per mantenere l’equilibrio corporeo sia per affrontare un problema specifico, in sinergia con l’allenamento." },
|
||||
{ q: "Trattate anche neonati e bambini?", a: "Sì, il nostro approccio comprende anche l’osteopatia neonatale e pediatrica." },
|
||||
{ q: "Serve una prescrizione medica?", a: "Non è necessaria; se disponibile, eventuale documentazione clinica è utile alla prima valutazione." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
slug: "nutrition",
|
||||
title: "Nutrizione",
|
||||
subtitle: "Alimentazione e stile di vita",
|
||||
excerpt: "La nutrizione in InsanityLab è un servizio personalizzato che integra alimentazione e allenamento per migliorare energia, performance e benessere con piani su misura e sostenibili.",
|
||||
longDescription: "Il servizio di nutrizione integra alimentazione e allenamento: piani su misura e sostenibili per migliorare energia, performance e benessere, costruiti sul tuo stile di vita.",
|
||||
features: ["Piani alimentari su misura", "Integrazione con l’allenamento", "Sostenibilità nel tempo", "Focus su energia e performance"],
|
||||
faq: [
|
||||
{ q: "Come inizia il percorso di nutrizione?", a: "Con una valutazione iniziale di abitudini, obiettivi e allenamento, da cui nasce il piano personalizzato." },
|
||||
{ q: "È integrato con il training?", a: "Sì: alimentazione e allenamento vengono programmati in modo coerente." },
|
||||
{ q: "Sono previsti controlli periodici?", a: "Sì, con aggiornamento del piano in base ai risultati." },
|
||||
],
|
||||
hasPricing: false,
|
||||
},
|
||||
{
|
||||
// Approfondimento del riquadro Training "Rehab" (fuori dalla griglia /services).
|
||||
slug: "rehab",
|
||||
title: "Rehab",
|
||||
subtitle: "Recupero post-infortunio, patologie muscolo-scheletriche",
|
||||
excerpt: "Accompagniamo il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena.",
|
||||
longDescription: "Il Rehab InsanityLab significa accompagnare il corpo nel recupero della sua funzionalità attraverso il movimento guidato. Non è fisioterapia in senso clinico: è il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento. Il servizio lavora con un ampio spettro di condizioni e ogni situazione viene valutata singolarmente con metodologie evidence based.",
|
||||
features: [],
|
||||
faq: [
|
||||
{ q: "Chi segue il percorso Rehab?", a: "Professionisti specializzati in rieducazione funzionale ed esercizio adattato, in sinergia con medici specialisti, fisioterapisti e osteopati." },
|
||||
{ q: "Serve una diagnosi medica?", a: "Se disponibile è utile portarla alla prima consulenza; il percorso si integra con le indicazioni dei sanitari." },
|
||||
{ q: "Quanto dura il percorso?", a: "Dipende dalla condizione di partenza: la progressione viene definita e aggiornata durante il percorso." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
// Approfondimento del riquadro Training "Holistic Class" (ex Pilates Flow / Performance Class).
|
||||
slug: "holistic-class",
|
||||
title: "Holistic Class",
|
||||
subtitle: "Classi fino a 8 persone con trainer certificato",
|
||||
excerpt: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti.",
|
||||
longDescription: "La Holistic Class lavora su mobilità articolare, respirazione ed equilibrio. Puoi seguirla da sola o integrarla nei tuoi allenamenti; è adatta a tutti i livelli e ogni sessione dura 40 minuti. Comprende lavoro di Pilates Flow, Mobility e Yoga.",
|
||||
features: ["Pilates Flow", "Mobility", "Yoga"],
|
||||
faq: [
|
||||
{ q: "A chi è adatta la Holistic Class?", a: "A chiunque voglia migliorare mobilità, respirazione ed equilibrio, a ogni livello di partenza." },
|
||||
{ q: "Posso integrarla con altri allenamenti?", a: "Sì: funziona sia come percorso a sé sia come complemento al tuo programma di allenamento." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
{
|
||||
// Approfondimento del riquadro Training "Reformer Class" (nuova).
|
||||
slug: "reformer-class",
|
||||
title: "Reformer Class",
|
||||
subtitle: "Classi fino a 5 persone con macchinari reformer",
|
||||
excerpt: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti.",
|
||||
longDescription: "La Reformer Class utilizza i macchinari reformer per il pilates in classi fino a 5 persone. Il lavoro è dedicato a core, postura e mobilità, a basso impatto sulle articolazioni e adatto a tutti i livelli. Ogni lezione dura 40 minuti.",
|
||||
features: ["Macchinari reformer", "Core, postura e mobilità", "Lavoro a basso impatto sulle articolazioni", "Classi fino a 5 persone", "Lezioni da 40 minuti"],
|
||||
faq: [
|
||||
{ q: "Serve esperienza con il reformer?", a: "No: le progressioni sono graduali e seguite da trainer certificati, adatte anche a chi inizia." },
|
||||
{ q: "Quante persone per classe?", a: "Massimo 5, per garantire attenzione e correzione costante." },
|
||||
],
|
||||
hasPricing: false,
|
||||
grid: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function getService(slug: string): Service | undefined {
|
||||
return services.find((p) => p.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const site = {
|
||||
name: "InsanityLab",
|
||||
phone: "351 7535977",
|
||||
email: "info@insanitylab.it",
|
||||
// Due ingressi dello stesso stabile. address = testo mostrato; mapQuery = punto geolocalizzabile per Google Maps.
|
||||
address: "Via Leandro Alberti 76 e Via De Mattiolo 5",
|
||||
cityLine: "40139 Bologna",
|
||||
mapQuery: "Via Leandro Alberti 76, 40139 Bologna",
|
||||
hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 19.00 · Dom chiuso"],
|
||||
socials: [
|
||||
{ label: "Instagram", url: "https://www.instagram.com/insanitylab" },
|
||||
{ label: "Facebook", url: "https://www.facebook.com/insanitylab" },
|
||||
{ label: "LinkedIn", url: "https://www.linkedin.com/company/insanitylab" },
|
||||
],
|
||||
navigation: [
|
||||
{ label: "About us", href: "/about" },
|
||||
{ label: "Training", href: "/training" },
|
||||
{ label: "Servizi", href: "/services" },
|
||||
{ label: "Blog & Edugo", href: "/blog" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
||||
],
|
||||
footerTraining: [
|
||||
{ label: "One to one", href: "/training#one-to-one" },
|
||||
{ label: "Small group", href: "/training#small-groups" },
|
||||
{ label: "Rehab", href: "/training#rehab" },
|
||||
{ label: "Holistic class", href: "/training#holistic-class" },
|
||||
{ label: "Reformer class", href: "/training#reformer-class" },
|
||||
{ label: "Coaching", href: "/training#coaching" },
|
||||
],
|
||||
footerServices: [
|
||||
{ label: "Personal Training", href: "/services/personal-training" },
|
||||
{ label: "Performance Class", href: "/services/performance-class" },
|
||||
{ label: "Coaching", href: "/services/coaching" },
|
||||
{ label: "Profilazione", href: "/services/profilazione" },
|
||||
{ label: "Osteopatia", href: "/services/osteopatia" },
|
||||
{ label: "Nutrizione", href: "/services/nutrition" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; imageAbout?: string; founder: boolean }
|
||||
|
||||
export const team: Member[] = [
|
||||
{ name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, imageAbout: "team-donata-dark", image: "team-donata",
|
||||
bio: "Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona l’area dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza." },
|
||||
{ name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, imageAbout: "team-nicola-dark", image: "team-nicola",
|
||||
bio: "Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e all’allenamento avanzato." },
|
||||
{ name: "Eva Cassetta", shortName: "Eva", role: "Business Manager – Balance & Strategy", founder: true, imageAbout: "team-eva-dark",
|
||||
bio: "Eva Cassetta ricopre il ruolo di Business Manager – Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo." },
|
||||
{ name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-alessandro-dark",
|
||||
bio: "Trainer – Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." },
|
||||
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer – Planning Manager", founder: false, imageAbout: "team-eleonora-dark", image: "team-eleonora",
|
||||
bio: "Trainer – Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
|
||||
{ name: "Alice Roda", shortName: "Alice", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-alice-dark",
|
||||
bio: "Trainer – Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove l’ascolto umano guida e supporta ogni tuo progresso." },
|
||||
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer – Functional & Movement", founder: false, imageAbout: "team-davide-dark",
|
||||
bio: "Trainer – Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature." },
|
||||
{ name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer – Pilates, Yoga & Mindfulness", founder: false, imageAbout: "team-giulia-dark",
|
||||
bio: "Trainer – Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e all’equilibrio mente-corpo." },
|
||||
];
|
||||
|
||||
export const homeTeam = ["Donata", "Nicola", "Eleonora"];
|
||||
@@ -0,0 +1,8 @@
|
||||
export const trainings = [
|
||||
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } },
|
||||
{ id: "small-groups", title: "Small Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } },
|
||||
{ id: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } },
|
||||
{ id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } },
|
||||
{ id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } },
|
||||
{ id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } },
|
||||
] as const;
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user?: { id: number; username: string };
|
||||
user?: { id: number; username: string; role: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
interface Props { title: string }
|
||||
const { title } = Astro.props;
|
||||
const user = Astro.locals.user;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Admin InsanityLab</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<style is:global>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
|
||||
.abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
|
||||
.awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
|
||||
.abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
|
||||
.abtn--danger { background: #a33; }
|
||||
.afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
|
||||
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
|
||||
.pill--draft { background: #f3d9a4; }
|
||||
.form-msg { font-size: .9rem; margin-top: 8px; }
|
||||
.form-msg--ok { color: #3c7a3c; }
|
||||
.form-msg--err { color: #b03030; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="abar">
|
||||
<strong>InsanityLab · Admin</strong>
|
||||
<span>
|
||||
{user && (
|
||||
<>
|
||||
{`Ciao, ${user.username}`}
|
||||
<a href="/admin">Articoli</a>
|
||||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
|
||||
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
|
||||
<a href="/admin/new">Nuovo</a>
|
||||
<a href="/admin/logout">Esci</a>
|
||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="awrap"><slot /></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/montserrat/700.css';
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { getDb } from '../lib/db';
|
||||
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
||||
|
||||
interface Props { title: string; description?: string }
|
||||
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
|
||||
|
||||
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
const user = tok ? getSessionUser(getDb(), tok) : null;
|
||||
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
|
||||
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
|
||||
const showTags = canTags && wantTags;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — InsanityLab</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:title" content={`${title} — InsanityLab`} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="sitemap" href="/sitemap.xml" />
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
<Header user={user} showTags={showTags} canTags={canTags} />
|
||||
<main><slot /></main>
|
||||
<Footer />
|
||||
<script>
|
||||
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('is-inview');
|
||||
io.unobserve(e.target);
|
||||
// A reveal concluso l'attributo va tolto, così la transition del reveal
|
||||
// non rallenta gli hover su transform del componente.
|
||||
e.target.addEventListener('transitionend', () => {
|
||||
e.target.removeAttribute('data-reveal');
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}, { rootMargin: '0px 0px -100px 0px' });
|
||||
document.querySelectorAll('[data-reveal], .section-heading').forEach((el) => io.observe(el));
|
||||
|
||||
const parallaxEls = document.querySelectorAll<HTMLElement>('[data-parallax]');
|
||||
if (parallaxEls.length && matchMedia('(min-width: 992px)').matches
|
||||
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
for (const el of parallaxEls) {
|
||||
const r = el.getBoundingClientRect();
|
||||
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
|
||||
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
|
||||
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
|
||||
}
|
||||
};
|
||||
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
|
||||
update();
|
||||
}
|
||||
</script>
|
||||
{showTags && (
|
||||
<style is:global>
|
||||
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
|
||||
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
|
||||
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
|
||||
.tag-badge:hover { opacity: 1; }
|
||||
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
|
||||
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
|
||||
.tag-modal[hidden] { display: none; }
|
||||
.tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
|
||||
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
|
||||
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
|
||||
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
|
||||
.tag-modal__box select { margin: 6px 8px 6px 0; }
|
||||
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
|
||||
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
|
||||
.tag-modal__save { background: #3a2d26; color: #fff; }
|
||||
.tag-modal__cancel { background: #ddd; }
|
||||
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
|
||||
</style>
|
||||
<div class="tag-modal" id="tag-modal" hidden>
|
||||
<div class="tag-modal__box">
|
||||
<h3 id="tm-tag"></h3>
|
||||
<p class="guid" id="tm-guid"></p>
|
||||
<div id="tm-body"></div>
|
||||
<div class="tag-modal__msg" id="tm-msg"></div>
|
||||
<div class="tag-modal__row">
|
||||
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
|
||||
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const STYLE_GROUPS = { size: ['s','m','l'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] };
|
||||
const modal = document.getElementById('tag-modal');
|
||||
const tmTag = document.getElementById('tm-tag');
|
||||
const tmGuid = document.getElementById('tm-guid');
|
||||
const tmBody = document.getElementById('tm-body');
|
||||
const tmMsg = document.getElementById('tm-msg');
|
||||
const tmSave = document.getElementById('tm-save');
|
||||
let current = null; // { tag, type, el }
|
||||
|
||||
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
|
||||
document.getElementById('tm-cancel').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||
|
||||
async function openModal(tag, el) {
|
||||
tmMsg.textContent = '';
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
|
||||
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
|
||||
data = await res.json();
|
||||
} catch { alert('Errore di rete.'); return; }
|
||||
current = { tag: data.tag, type: data.type, el };
|
||||
tmTag.textContent = data.tag;
|
||||
tmGuid.textContent = data.guid || '';
|
||||
tmBody.innerHTML = '';
|
||||
if (data.type === 'image') {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
|
||||
tmBody.appendChild(inp);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
|
||||
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
|
||||
tmBody.appendChild(del);
|
||||
del.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
|
||||
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.id = 'tm-value'; ta.value = data.value;
|
||||
tmBody.appendChild(ta);
|
||||
(data.styleOptions || []).forEach((group) => {
|
||||
const opts = STYLE_GROUPS[group];
|
||||
if (!opts) return;
|
||||
const sel = document.createElement('select');
|
||||
sel.dataset.group = group;
|
||||
sel.className = 'tm-style';
|
||||
const none = document.createElement('option');
|
||||
none.value = ''; none.textContent = group + ': —';
|
||||
sel.appendChild(none);
|
||||
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
|
||||
tmBody.appendChild(sel);
|
||||
});
|
||||
}
|
||||
modal.hidden = false;
|
||||
}
|
||||
|
||||
tmSave.addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
tmMsg.textContent = '';
|
||||
if (current.type === 'image') {
|
||||
const file = document.getElementById('tm-file').files[0];
|
||||
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
|
||||
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
|
||||
return;
|
||||
}
|
||||
const value = document.getElementById('tm-value').value;
|
||||
const styles = {};
|
||||
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
|
||||
const body = { value };
|
||||
if (Object.keys(styles).length) body.styles = styles;
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
|
||||
const saved = await r.json();
|
||||
// Aggiorna in-place il contenuto nel DOM.
|
||||
if (current.type === 'html') current.el.innerHTML = saved.value;
|
||||
else current.el.textContent = saved.value;
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-tag]').forEach((el) => {
|
||||
const tag = el.dataset.tag;
|
||||
const guid = el.dataset.guid || '';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tag-badge';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
|
||||
const edit = document.createElement('button');
|
||||
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
|
||||
edit.addEventListener('click', () => openModal(tag, el));
|
||||
badge.appendChild(label); badge.appendChild(edit);
|
||||
document.body.appendChild(badge);
|
||||
const place = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
badge.style.top = window.scrollY + r.top - 16 + 'px';
|
||||
badge.style.left = window.scrollX + r.left + 'px';
|
||||
};
|
||||
place();
|
||||
addEventListener('scroll', place, { passive: true });
|
||||
addEventListener('resize', place);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,8 @@ import { randomBytes } from 'node:crypto';
|
||||
export const SESSION_COOKIE = 'session';
|
||||
const SESSION_DAYS = 7;
|
||||
|
||||
export type Role = 'admin' | 'superuser' | 'user';
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
return bcrypt.hashSync(plain, 12);
|
||||
}
|
||||
@@ -13,9 +15,9 @@ export function verifyPassword(plain: string, hash: string): boolean {
|
||||
return bcrypt.compareSync(plain, hash);
|
||||
}
|
||||
|
||||
export function createUser(db: Database.Database, username: string, password: string): number {
|
||||
const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
.run(username, hashPassword(password));
|
||||
export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
|
||||
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run(username, hashPassword(password), role);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -30,19 +32,83 @@ export function login(db: Database.Database, username: string, password: string)
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null {
|
||||
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null {
|
||||
const row = db.prepare(
|
||||
`SELECT s.token, s.expires_at, u.id, u.username
|
||||
`SELECT s.token, s.expires_at, u.id, u.username, u.role
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
|
||||
).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined;
|
||||
).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined;
|
||||
if (!row) return null;
|
||||
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
return null;
|
||||
}
|
||||
return { id: row.id, username: row.username };
|
||||
return { id: row.id, username: row.username, role: row.role };
|
||||
}
|
||||
|
||||
export function logout(db: Database.Database, token: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
|
||||
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
|
||||
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
|
||||
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
|
||||
// utenti già autenticati).
|
||||
const RULES: [RegExp, Role[]][] = [
|
||||
[/^\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||||
];
|
||||
|
||||
export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||||
if (role === 'admin') return true;
|
||||
for (const [re, roles] of RULES) {
|
||||
if (re.test(pathname)) return roles.includes(role as Role);
|
||||
}
|
||||
return true; // blog, upload, logout, showtags: tutti i loggati
|
||||
}
|
||||
|
||||
export function getUsernameById(db: Database.Database, id: number): string | null {
|
||||
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
|
||||
return row?.username ?? null;
|
||||
}
|
||||
|
||||
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
|
||||
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
|
||||
{ id: number; username: string; role: string }[];
|
||||
}
|
||||
|
||||
export function countAdmins(db: Database.Database): number {
|
||||
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
|
||||
}
|
||||
|
||||
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
|
||||
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
|
||||
{ id: number; username: string; role: string } | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
}
|
||||
|
||||
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
|
||||
}
|
||||
|
||||
export function deleteUser(db: Database.Database, id: number): void {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function randomPassword(): string {
|
||||
return randomBytes(9).toString('base64url'); // ~12 caratteri
|
||||
}
|
||||
|
||||
export function isRole(v: unknown): v is Role {
|
||||
return v === 'admin' || v === 'superuser' || v === 'user';
|
||||
}
|
||||
|
||||
export function landingFor(role: string): string {
|
||||
if (role === 'superuser') return '/admin/content';
|
||||
return '/admin';
|
||||
}
|
||||
|
||||