Compare commits

...

117 Commits

Author SHA1 Message Date
Adriano 21d9755e37 Migrazione: corregge il titolo Performance Class troncato a "Class"
Nei DB esistenti (prod + dev) service.performance-class.title era rimasto
"Class", mostrando "CLASS" come H1 della pagina /services/performance-class
e nelle card di /services e home. Il seed è INSERT OR IGNORE e non lo
aggiornava; una nuova installazione era invece corretta.

Aggiunge una migrazione guardata dal vecchio valore (idempotente, non tocca
le modifiche del pannello) che allinea a "Performance Class". Verificata su
copia del DB di produzione: titolo corretto, 40 edit del pannello intatti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:41:37 +02:00
Adriano ee54e9a1f4 Migrazione: allinea i bottoni promo col vecchio testo-freccia
Il commit 1522d97 ha spostato la freccia "→" dei bottoni promo nel CSS
(::after) e il maiuscolo in text-transform, cambiando il seed in minuscolo
senza freccia. Ma il seed è INSERT OR IGNORE: sui DB esistenti (produzione)
le righe restavano a "LE NOSTRE PROPOSTE →" / "ACQUISTA →", e il ::after
avrebbe prodotto una doppia freccia.

Aggiunge una migrazione guardata dal vecchio valore (idempotente, non tocca
le righe modificate dal pannello) che le allinea al seed corrente. Verificata
su copia del DB di produzione: frecce corrette e 40 edit del pannello intatti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:28:20 +02:00
Adriano 62e0120ea2 Footer: rimuove il form contatti e riorganizza in 3 colonne
- Rimossa la colonna "invia messaggio" (nome, cognome, telefono, mail,
  messaggio) e l'import ContactForm inutilizzato.
- Griglia a 3 colonne con contenuto centrato, logo incluso.
- Training e Servizi allineati alla riga "Promuoviamo il movimento...".
- Altezza del footer ridotta (padding compattato).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:48:49 +02:00
Adriano 2f94fc0373 Allinea test stale e migrazione nav dopo riordino menu
I test erano rimasti indietro rispetto a due modifiche intenzionali:
- size 'xxl' è ora un valore valido (whitelist + CSS): i test lo usavano
  come esempio di valore da rifiutare → sostituito con 'xxxl'.
- il menu è stato riordinato (commit 5ff4771): la voce "Servizi" è passata
  da nav.3 a nav.2. Allineata la voce di migrazione OLD_SECTION_DEFAULTS a
  global.nav.2.label e il relativo test. Impatto nullo sui DB reali (nessuna
  riga ha ancora value 'Programmi'), solo coerenza semantica.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:17:37 +02:00
Adriano e6c493b756 Contatti obbligatori, hero senza foto, menu Promo2 solo admin
- Contatti: nome, cognome, telefono e mail resi obbligatori (validazione
  client + server); il form del footer ora include cognome e telefono.
- Home hero: rimossa la foto (file conservato su disco), fascia ridotta
  da 100vh a padding contenuto, testo allargato.
- Menu: la voce "Promo 2 (test)" è visibile solo agli admin loggati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:06:28 +02:00
Adriano 1522d97f54 promo: freccia bottoni via ::after (modifiche dal canvas Claude Design)
Riporta nel codice le modifiche fatte da Adriano nel canvas di Claude Design: la
freccia dei bottoni diventa decorazione CSS (::after) invece di far parte del testo
del tag. I tag promo.hero.cta e promo.buy-label perdono la freccia e il maiuscolo
forzato (gestiti dal CSS .btn). promo-eventi2 usa la ::after globale di .btn;
promo-eventi (.lp) aggiunge ::after ai suoi bottoni custom per non perdere la freccia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:08:20 +02:00
Adriano cc21cb7627 promo: riabilita i controlli di stile sui badge nel pannello contenuti
promo.hero.badge e promo.launch.badge tornano al set di stili di default
(size/color/weight/style/align): erano stati seedati con styleOptions vuoto,
quindi il pannello mostrava solo il testo senza opzioni di stile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:22:37 +02:00
Adriano 27fdf1fc30 promo-eventi2: raffinamento estetico
Claim su due righe pulite (niente più spezzatura del corsivo), titolo FAQ reso
visibile e uppercase come lo stile del sito, card con prezzi più prominenti,
separatore feature, ombra/hover e featured evidenziata, spaziature bilanciate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:26:52 +02:00
Adriano ae5eef037b promo-eventi2: adotta i pattern di layout del sito (PageHero, section, sfondo bianco)
La prima versione unificava solo font/colori ma teneva il box centrato su sfondo crema.
Ora la pagina di test usa il banner PageHero a tutta larghezza, sezioni .section/.container
su sfondo bianco e bottoni .btn globali, come Servizi e Training. Aggiunto il tag
promo.pagehero.title per il titolo del banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:11:09 +02:00
Adriano 931c1e43ad Aggiunge pagina di test promo-eventi2 con stili unificati al design system
Variante di /promo-eventi con font Montserrat/Open Sans e palette --c-* al posto di
Inter e dei colori hardcoded; struttura e contenuti identici (stessi tag promo.*).
Root class `.lp2` per isolare gli stili is:global dalla pagina originale. Aggiunta la
voce di menu "Promo 2 (test)" → /promo-eventi2 per il confronto affiancato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:51:42 +02:00
Adriano a7b05d703a Promo ed eventi: testi editabili dal pannello con stili is:global
Riprende il tagging della landing (tag promo.* + global.whatsapp centralizzato) e
risolve la regressione visiva: gli elementi editabili sono renderizzati dal componente
<T>, fuori dallo scope della pagina, quindi il CSS scoped non li raggiungeva (font e
allineamenti persi). Lo <style> diventa is:global; ogni regola è già prefissata `.lp`,
quindi resta confinata alla landing senza toccare il resto del sito.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:43:40 +02:00
Adriano 4cee8cdd7a Revert "Rende la pagina Promo ed eventi modificabile dal pannello contenuti"
This reverts commit 1742c1d666.
2026-07-13 11:54:10 +02:00
Adriano 1742c1d666 Rende la pagina Promo ed eventi modificabile dal pannello contenuti
Tutti i testi della landing (hero, launch, card con prezzi, nota validità e FAQ)
passano ora dal sistema di content-tag `promo.*`, editabili inline e da pannello.
Il numero WhatsApp è centralizzato nel tag globale `global.whatsapp`: le risposte
FAQ usano il segnaposto {{whatsapp}}, sostituito a runtime, così si aggiorna in un
punto solo. Struttura delle 4 card e link di acquisto bsport restano nel codice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:51:21 +02:00
Adriano 0b938d7d09 Aggiunge integrazione Google Tag Manager attivabile via env
GTM viene caricato nel layout Base solo se la variabile GTM_ID è valorizzata.
L'ID è letto a runtime da process.env (non inlinato a build-time), così si
attiva/disattiva senza ricompilare. Con GTM_ID vuoto il sito non carica alcuno
script Google.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:59:34 +02:00
Adriano 47a93049bb editor inline: restyle e combo con valore impostato pre-selezionato
- GET /api/admin/content/[tag] restituisce anche gli styles correnti del blocco
- Modal ridisegnato (header con chip tag, input arrotondati, griglia combo)
- Combo etichettate in italiano (Dimensione/Colore/Peso/Stile/Allineamento) con
  valori leggibili; pre-selezione del valore già impostato ed evidenziazione

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:18:08 +02:00
Adriano 5ff47711cd menu: riordina le voci (Training, Servizi, About us, Blog & Edugo, Promo, Contatti)
I label del menu vengono da global.nav.N.label (per posizione): riordinati anche
i blocchi contenuto in produzione per allinearli al nuovo ordine degli href.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:54:55 +02:00
Adriano f48518d8fd footer e contatti: social aggiornati e nome sulla mappa
- Footer: Instagram -> in_sanity_lab, LinkedIn -> in-sanity-lab-srl, rimosso Facebook
- Contatti: mapQuery con nome "IN-SANITY LAB" così la mappa mostra l'etichetta

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:48:58 +02:00
Adriano 30b509436e contenuti: aggiunge size xl/xxl e allineamento (con giustificato)
- style-presets: size += xl, xxl; align += justify
- global.css: .cs-size-xl (1.5em), .cs-size-xxl (2em), .cs-align-justify
- content-seed: 'align' nei default styleOptions dei tag testo/html, così il
  picker orientamento (sx/centrato/dx/giustificato) compare nell'editor
- Base.astro: sincronizza la copia hardcoded degli stili nell'editor inline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:48:58 +02:00
Adriano 8f47cf9e94 about: rende visibili gli a-capo in about.studios.body3
Il <p> reso da <T> collassava i newline (white-space: normal). Aggiunto
white-space: pre-line al paragrafo, ancorato al contenitore col cid via :global.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:19:29 +02:00
Adriano 9f6cf29708 menu: rinomina voce "Insanitylab" in "About us"
Aggiornato il seed del label nav (site.ts). Il valore renderizzato viene dal
blocco taggato global.nav.1.label, aggiornato anche in produzione.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:15:36 +02:00
Adriano 3183bd795d training: nasconde la sezione Agenda
Rimosso il render di <Agenda /> (e l'import) da /training. Il componente
Agenda.astro resta disponibile per riuso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:12:39 +02:00
Adriano 1b9a3c1343 Servizi home, testo promo, fix immagini override e rimozione Antonello
- ServicesGrid: filtra grid !== false come /services (via Small Group, Rehab,
  Holistic Class, Reformer Class dalla griglia Servizi in home)
- promo-eventi: badge "solo per apertura nuova sede Settembre 2026"
- MethodSteps e about: .tcard/.method__circle usano :global(img) così gli
  override <img> di <TImg> (senza cid) ricevono border-radius/object-fit
- about: griglia team a flex centrato, ultima riga incompleta non piu a sinistra
- team: rimosso Antonello D'Alessandro (staff about e homeTeam)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:01:52 +02:00
Adriano 3b8983dca5 fix(img): foto Reformer Class in alta risoluzione
Sostituita la foto piccola (770px) con la versione HD della stessa scena
reformer (1600x1066) dagli asset Figma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:41:51 +02:00
Adriano 3b4d38e087 fix(img): foto corretta per Holistic Class
La foto di Holistic Class mostrava macchinari Reformer (era la stessa immagine
di Reformer Class). Sostituita con una foto Pilates/Yoga a corpo libero (due
persone di età diverse, coerente con 'adatta a tutti i livelli'), dagli asset Figma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:32:19 +02:00
Adriano 02b5180227 fix(metodo): allinea i testi degli step del Metodo ai valori voluti
Gli step del blocco Metodo (home) erano stati riscritti dal pannello ma solo nel
DB di sviluppo: il database non è versionato, quindi non erano mai arrivati in
produzione (che mostrava ancora i default del seed). Aggiornato il seed method.ts
(Check-up fisico, Insanity plan, Monitoraggio costante + testi) e aggiunta
migrazione idempotente che allinea le righe di prod ancora ai vecchi default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:32:44 +02:00
Adriano c722680590 chore(deploy): vecchio insanitylab.tielogic.xyz → 301 all'apex
Conclusa la migrazione al dominio ufficiale: il sottodominio di transizione
non serve più il sito ma redirige permanentemente a insanitylab.it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:25:45 +02:00
Adriano 172cef236b feat(gdpr): conformità privacy — mappa a consenso, pagine legali, titolare
- Mappa Google su /contact ripristinata a click-to-load (consenso esplicito):
  nessun dato inviato a Google prima del click dell'utente
- Nuove pagine /privacy e /cookie-policy (informativa art. 13 GDPR e cookie),
  compilate con i dati del titolare IN-SANITY LAB S.R.L. (P.IVA 03927341200)
- Footer: ragione sociale + P.IVA e link a Privacy e Cookie Policy
- Form contatti: link all'informativa privacy sotto l'invio
- sitemap: aggiunte /privacy e /cookie-policy

Testi legali da far validare; base tecnica conforme (no analytics/tracker,
solo cookie tecnici, font self-hosted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:19:57 +02:00
Adriano 1aa82f5332 docs: README unico e aggiornato; rimozione piani/spec superati
- README riscritto come documentazione viva e completa: stack, funzionalita'
  (contenuti taggati + GUID + modifica inline, 3 ruoli admin/superuser/user,
  blog ownership, gestione utenti), env SMTP Aruba, deploy Docker/Traefik,
  dominio insanitylab.it, migrazioni DB e trappole tecniche note
- rimossi docs/superpowers/plans e docs/superpowers/specs: piani gia' eseguiti
  e spec di design ora accorpati nel README (restano nella storia git)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:50:21 +02:00
Adriano d79aa912b1 feat(deploy): dominio ufficiale insanitylab.it (apex canonico)
- astro.config: site=https://insanitylab.it, allowedDomains apex+www+tielogic
- compose/Traefik: router serve insanitylab.it e (in transizione) tielogic.xyz;
  www.insanitylab.it redirige 301 all'apex. TLS Let's Encrypt via certresolver.
  tielogic resta servito per non avere downtime finché il DNS non propaga;
  il suo redirect all'apex verra' attivato dopo la conferma che il dominio e' live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:20:38 +02:00
Adriano 86d608a929 fix(hero): titolo su 3 righe (PERFORMANCE./BALANCE./LONGEVITY.)
Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
di HeroSlider, quindi le regole scoped .hero__line (display:block e animazioni)
non lo colpivano e le righe restavano inline. Ancorato a .hero__text/h1 (che il
cid ce l'hanno) con :global(.hero__line), come già fatto per .prog__desc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:54:50 +02:00
Adriano 708410bbf1 feat(home): rimuove le sezioni Agenda e Team dalla pagina principale
Tolte da index.astro le sezioni <Agenda> e <TeamSection> con i relativi import.
I componenti restano nel codice (riutilizzabili altrove). Modifica isolata su
branch dedicato per valutazione col cliente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:47 +02:00
Adriano 53af0ca00e feat(home): nuova immagine per lo step "Insanity plan" del Metodo
Grafica di brand (barre di progressione, curva, checklist protocolli) al posto
della vecchia foto, coerente col testo aggiornato dello step 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:40:57 +02:00
Adriano 6594f6da53 docs(env): configurazione SMTP Aruba per il form contatti
smtps.aruba.it:465 con casella autenticata del dominio (il mittente deve
coincidere con la casella SMTP). Il dominio invia già la posta via Aruba
(SPF include:aruba.it). Credenziali reali solo nel .env (git-ignored) e sul VPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:41:20 +02:00
Adriano c6de02be83 Contatti, /training e immagini: rifiniture post-merge
- Rimossa la sezione "Prenota la lezione di prova" da /training (markup,
  import, CSS, seed e blocchi DB relativi)
- Titoli dei riquadri Training non più forzati in minuscolo: rispettano il
  testo del DB (One to One, Small Groups, ecc.)
- Footer e pagina Contatti con i due ingressi dello stesso stabile
  (Via Leandro Alberti 76 e Via De Mattiolo 5) più l'email; nuovo tag
  global.contact.map-query e migrazione DB idempotente dell'indirizzo
- Mappa Google in /contact caricata automaticamente e a piena dimensione
  (rimosso il bottone e il relativo script)
- Landing Promo ed eventi: rimossa la barra contatti in fondo
- service-icons allineate agli slug attuali (one-to-one, small-group;
  rimosse le chiavi obsolete personal-training e pilates-flow)
- Immagini: foto Rehab corretta (prima era di osteopatia) su program-rehab
  e training-rehab; bande nere letterbox rimosse dalla foto di osteopatia;
  nuova immagine grafica per Profilazione con grafici HRV (ECG, tacogramma
  R-R, Poincaré plot)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:41:37 +02:00
Adriano 11fc2b3e54 Servizi e landing Promo ed eventi: rinomine, pulizia contenuti e nuova pagina
Servizi:
- Personal Training rinominato "One to One" (slug /services/one-to-one) con
  redirect 301 dal vecchio URL e migrazione DB idempotente dei contenuti taggati
- Nuova pagina "Small Group" (slug /services/small-group, fuori dalla griglia)
- Rimossi i titoli intro di /training e /services e le liste feature di One to
  One, Small Group, Rehab e Osteopatia; la lista feature ora è condizionale
- Fix elenco puntato nel long-description (white-space: pre-line, selettore
  ancorato al container per gli elementi resi dal componente <T>)
- Immagine di Performance Class aggiornata
- Nella griglia Servizi i riquadri "One to One" e "Performance Class" rimandano
  a /training; nella pagina Training i riquadri One to One e Small Group aprono
  le rispettive schede servizio

Landing:
- Pagina "Promo ed eventi" (slug /promo-eventi) integrata nel sito con header e
  footer condivisi, basata sul nuovo mockup di lancio
- Card a 5 incontri con prezzi edizione apertura, FAQ ad accordion e acquisto
  tramite gestionale bsport
- Nuova voce di menu "Promo ed eventi"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:33:11 +02:00
Adriano 5b34ee1697 Home: bottone Contattaci in fondo alla sezione Metodo
Come da documento cliente. Nuovo tag home.method.cta (auto-seed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 17:34:57 +02:00
Adriano 2468ce95f7 About: testi aggiornati e rimozione sezioni Metodo, Vision, StoryLab
- Testi riscritti da documento cliente: about.about.body, convenzioni.item1/item2
  (rimosso "gratuita"), studios.body1 (versione breve). Seed + migrazione DB
  idempotente (guardia via frammento LIKE, rispetta le modifiche da pannello).
- Rimosse dalla pagina le sezioni marcate "LEVARE" nel documento: Timeline/StoryLab,
  Metodo (presente in home) e Vision. I tag restano nel seed per un eventuale ripristino.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 16:43:50 +02:00
Adriano 9966ec3616 Training: 6 riquadri (Rehab, Holistic Class, Reformer Class, Coaching)
- trainings.ts: da 4 a 6 riquadri (One to One, Small Group max 4, Rehab,
  Holistic Class ex Performance Class, Reformer Class nuova, Coaching ex Fit
  Remote); anche in Homepage. CTA "Scopri" verso le pagine dettaglio.
- services.ts: aggiunte pagine dettaglio rehab/holistic-class/reformer-class con
  flag grid:false (fuori dalla griglia /services ma raggiungibili come
  approfondimento dai riquadri Training). Rehab torna consultabile su /services/rehab.
- index/[slug] servizi filtrano grid!==false; rimosso redirect /services/rehab.
- Footer Training portato a 6 voci con ancore aggiornate; TrainingCards tone-safe.
- Foto Reformer dal documento cliente; card Rehab/Holistic/Coaching con
  immagini placeholder (da confermare). Migrazione DB idempotente per label/meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 13:26:58 +02:00
Adriano 140146fce1 Servizi: Pilates Flow -> Profilazione, Rehab -> Osteopatia
- services.ts: nuovi servizi profilazione (copy bozza dal metodo ISL) e
  osteopatia (copy dal documento cliente); rehab esce da Servizi (va in Training).
- Redirect 301 dai vecchi slug: pilates-flow -> profilazione, rehab -> training.
- Footer, icone, sitemap e meta aggiornati; migrazione DB idempotente per le
  label footer e la meta description (contenuti live in SQLite).
- Immagine osteopatia riusa la foto rehab (come da mockup cliente).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 13:10:37 +02:00
Adriano 7211d74d3a fix(sicurezza): safeNext blocca backslash (open redirect) + guardia ownership su /admin/edit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:34:16 +02:00
Adriano e2bbc9074f fix(create-user): ruoli aggiornati a superuser/user (gap dal piano) 2026-07-05 22:23:32 +02:00
Adriano be77285613 test(smoke): login pubblico, badge, protezione /admin/users 2026-07-05 22:21:30 +02:00
Adriano c36ad4cb2d feat(users): gestione utenti /admin/users con guardie self/ultimo-admin 2026-07-05 22:18:20 +02:00
Adriano 0f2b2ba014 feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab 2026-07-05 22:15:30 +02:00
Adriano 2bf6b3d4de feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore 2026-07-05 22:13:15 +02:00
Adriano 22d72be74d feat(inline): badge con guid + modal di modifica contenuti in pagina 2026-07-05 22:10:50 +02:00
Adriano 842371604b feat(showtags): endpoint cookie + toggle nel menu utente 2026-07-05 22:05:46 +02:00
Adriano 71cd1626e0 feat(header): identità utente SSR, link Accedi / menu utente 2026-07-05 22:03:07 +02:00
Adriano 6b85872515 feat(login): pagina pubblica /login con next sicuro 2026-07-05 21:59:50 +02:00
Adriano 5ce10da9bd feat(content): guid in resolve/componenti + GET /api/admin/content/[tag] 2026-07-05 21:56:53 +02:00
Adriano c3267c9be2 feat(posts-api): assegna autore in POST, ownership su PUT/DELETE 2026-07-05 21:51:34 +02:00
Adriano 6d1e2607ee feat(posts): author_id nel repository + listByAuthor + helper utenti 2026-07-05 21:49:16 +02:00
Adriano db92fc9c02 feat(middleware): autorizzazione a matrice + redirect per ruolo 2026-07-05 21:47:24 +02:00
Adriano e87a464090 feat(auth): matrice permessi 3 ruoli + landingFor 2026-07-05 21:44:30 +02:00
Adriano 5cea895d51 feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id 2026-07-05 21:40:28 +02:00
Adriano d4cf359805 docs: rifinisci test migrazione nel piano 2026-07-05 21:38:47 +02:00
Adriano d0fa75a3d3 docs: piano implementazione ruoli/login/GUID/modifica inline 2026-07-05 21:37:53 +02:00
Adriano 6c225281e0 docs: spec ruoli/login/GUID/modifica inline 2026-07-05 21:14:30 +02:00
Adriano 8ceea9b16f feat: rinomina sezione Programmi in Servizi (route, tag, migrazione DB)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:23:48 +02:00
Adriano c975dcac12 fix review finale: card programmi home taggate, README gestione contenuti, import type e commento seed 2026-07-05 17:45:31 +02:00
Adriano 8495d9edd4 test: smoke contenuti taggati e utente editor 2026-07-05 17:38:33 +02:00
Adriano 56ca6eca14 feat: overlay annotate per utenti loggati 2026-07-05 17:37:02 +02:00
Adriano 608b1b68d2 fix: pannello contenuti — PUT ritorna value salvato, data-q aggiornato, guard hash, aria-label 2026-07-05 17:35:41 +02:00
Adriano be52781499 feat: pannello admin contenuti
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:30:39 +02:00
Adriano bf19cba4d3 fix: guard body null su PUT contenuti, formato tag e suffisso random su upload immagine 2026-07-05 17:22:23 +02:00
Adriano eda4d73aac feat: API contenuti taggati
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:18:30 +02:00
Adriano b5c0c107bc chore: rimossa dep @astrojs/sitemap, README aggiornato 2026-07-05 17:14:10 +02:00
Adriano 685ef4a36e feat: pagine vetrina in SSR e sitemap runtime 2026-07-05 17:11:20 +02:00
Adriano 3551fae132 fix: hero contact ottimizzata senza override, mapsQuery da tag 2026-07-05 17:08:41 +02:00
Adriano bf38fc865c feat: tag contenuti training, contact e 404 2026-07-05 17:04:17 +02:00
Adriano 31354c4457 feat: tag contenuti programmi e pricing 2026-07-05 16:48:03 +02:00
Adriano b84463c9df feat: tag short-name membri team 2026-07-05 16:33:39 +02:00
Adriano d5b19f2c39 feat: tag contenuti pagina about e timeline 2026-07-05 16:29:55 +02:00
Adriano 34fd3d2e18 feat: tag contenuti home cards, team, metodo, partner, agenda 2026-07-05 16:17:31 +02:00
Adriano 3f01721a99 feat: tag contenuti home hero, info, feature, quote 2026-07-05 16:04:27 +02:00
Adriano f4c350ad64 feat: tag contenuti header, footer e form contatti 2026-07-05 15:54:29 +02:00
Adriano b079ded266 feat: componenti T e TImg per contenuti taggati 2026-07-05 15:46:11 +02:00
Adriano b22cc40b03 feat: content store con seed, cache e fallback 2026-07-05 15:43:29 +02:00
Adriano 6f51aec4c6 feat: preset stili contenuto con whitelist 2026-07-05 15:40:09 +02:00
Adriano 5f1d9f3740 feat: middleware limita editor ai percorsi contenuti 2026-07-05 15:38:21 +02:00
Adriano 0f94b5017c docs: fix regex EDITOR_ALLOWED nel piano (bypass prefisso) 2026-07-05 15:37:24 +02:00
Adriano 274a6c7600 fix: regex percorsi editor senza bypass prefisso 2026-07-05 15:36:38 +02:00
Adriano 77041f9d5a feat: ruoli admin/editor in auth e create-user 2026-07-05 15:31:53 +02:00
Adriano c7d9d4ba5d feat: tabella content_blocks e colonna role utenti 2026-07-05 15:28:17 +02:00
Adriano 83237feb65 docs: piano implementazione contenuti taggati + inventario
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:26:26 +02:00
Adriano b83d62c10c docs: fix stile tabelle markdownlint in spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:14:32 +02:00
Adriano 875b369939 docs: spec sistema contenuti taggati e pannello editor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:10:17 +02:00
Adriano 98c42aae54 Allinea PageHero, lista programmi e dettaglio programma al design Figma
- PageHero: banner scuro min-height 380px, titolo bianco uppercase ~44px,
  variante con foto di sfondo e overlay scuro
- /programs: card con foto (aspect 4/3, hover zoom), ombra, reveal stagger
- Dettaglio programma: hero con foto, immagine grande nel corpo, categorie
  corrette da Figma, sidebar con icone programmi, pricing con card colorate
  tan/taupe/dark e prezzo grande
- Nuove foto card program-*.jpg e modulo condiviso program-icons.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:10:36 +02:00
Adriano 7d2f0b7048 fix: colonne info con aspect ratio figma 413/308 2026-07-02 15:52:10 +02:00
Adriano ecdf12ee0e fix: hero a piena altezza con testo sovrapposto come da figma 2026-07-02 14:40:15 +02:00
Adriano 2d5ff1816d feat: hero completo uomo+donna, donna estratta dal fig via mcp figma 2026-07-02 14:16:31 +02:00
Adriano 6cc1cf5f44 fix: rimappa tutte le foto sugli slot corretti del design figma
Le immagini erano asset reali del .fig ma piazzate in slot sbagliati.
Rimappate home/training/about dal confronto con i PDF Figma; aggiunti
i 9 ritratti team about (chiari in home, scuri in about via imageAbout),
immagini mancanti per metodo/vision/convenzioni/studios/trial e hero
contatti. Fix imgOf (match esatto) e getImage per i background dedupati.
2026-07-02 13:56:55 +02:00
Adriano 0d9b645636 feat: animazioni stile template prowess (reveal, parallax, marquee, hero, hover) 2026-07-02 13:08:09 +02:00
Adriano e54daa105f docs: spec animazioni stile template prowess 2026-07-02 12:58:34 +02:00
Adriano eccc7ee237 fix: trust x-forwarded dietro traefik via security.allowedDomains 2026-07-02 11:50:47 +02:00
Adriano 3c949da75a feat: dockerfile multi-stage, compose con traefik per deploy vps 2026-07-02 11:44:42 +02:00
Adriano 6d1963737e fix: sitemap senza admin, categorie sidebar reali, 404 blog, og:image e README
- IMP-1: sitemap filter esclude /admin
- IMP-2: [slug].astro programmi usa CATEGORIES da blog-const con link /blog?categoria=
- MIN-1: blog/[slug].astro ritorna 404 vera invece di redirect
- MIN-3: public/img/og.jpg + meta og:image in Base.astro
- README: porta 3000→4321, Astro 5→7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:05:30 +02:00
Adriano 29d633c795 chore: robots, favicon e smoke test 2026-07-02 01:56:54 +02:00
Adriano f0ad162f74 feat: area admin con editor tiptap, api crud e upload immagini
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:49:12 +02:00
Adriano f3fb2fe005 feat: blog pubblico con filtri, paginazione e articolo 2026-07-02 01:31:59 +02:00
Adriano e9840105a3 feat: pagina contatti e api email con validazione testata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:26:49 +02:00
Adriano be4d933ca3 feat: pagine programmi con dettaglio e pricing performance class 2026-07-02 01:22:43 +02:00
Adriano ed35491d4c feat: pagina training
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:18:33 +02:00
Adriano c4cb05e142 fix: immagine sezione about a risoluzione adeguata
about-team-group.jpg (270×480) vincolato a max-width 320px per evitare
stiramento a 50vw (~650px). widths ridotto a [270], sizes="270px".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:16:32 +02:00
Adriano ba89568a38 feat: pagina about 2026-07-02 01:10:56 +02:00
Adriano 45e5214417 feat: homepage completa da design Figma
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:01:21 +02:00
Adriano 2fdd1b9a7f feat: layout base con header, footer e 404 2026-07-02 00:48:14 +02:00
Adriano ac75720bd9 fix: apostrofi tipografici nei dati statici 2026-07-02 00:46:14 +02:00
Adriano 50ce224d11 feat: dati statici contenuti da design Figma 2026-07-02 00:42:01 +02:00
Adriano 6caba6fee1 feat: autenticazione a sessioni, middleware admin e script create-user
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:36:07 +02:00
Adriano d3b5e67132 feat: layer sqlite e repository posts con test 2026-07-02 00:33:23 +02:00
Adriano 42d603b55b feat: slugify, rate limit ed env helper con test 2026-07-02 00:31:18 +02:00
Adriano c9a9ff91fd feat: design tokens e css globale 2026-07-02 00:28:50 +02:00
Adriano 4445c422f8 fix: loghi insanitylab estratti dal pdf figma al posto dei loghi template
- logo-dark.png (600×108) e logo-light.png (600×342) ora estratti da
  FigmaDoc/Homepage.pdf via pdftoppm 300dpi + ImageMagick, con alpha
- scripts/extract-logos.sh: pipeline riproducibile e idempotente (md5 stabile)
- asset-map.json: rimossa sezione fallbacks ../Dati (fonte errata PROWESS++)
- apply-asset-map.mjs: commento pipeline aggiornato a 4 step

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:25:18 +02:00
Adriano 810e5d8dcd fix: limite altezza in optimize-assets, pesi immagini e report dimensioni
- optimize-assets.sh: resize da 1400x> a 1400x1600> (vincola anche altezza);
  qualità JPG 85→83; tutti i JPG esistenti ri-codificati (non solo PNG→JPG)
- asset-map.json: aggiunta chiave _nota con avviso conversione PNG→JPG
- apply-asset-map.mjs: skip chiavi JSON con prefisso _ (chiavi meta)
- src/assets/img: 11 MB → 3.7 MB, nessun file >500 KB (max: quote-bg 460 KB)
- task-2-report.md: sezione "Dimensioni finali" corretta + "Fix round 2"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:14:56 +02:00
Adriano 551957e2e1 fix: pipeline asset riproducibile con script di ottimizzazione
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:06:54 +02:00
Adriano d8ef59e370 feat: asset immagini estratti da Figma e mappati
Estrazione da Web Insanity.fig, identificazione visiva, mappatura
semantica e ottimizzazione peso. 31 immagini in src/assets/img/,
2 loghi InsanityLab (fallback da Dati/) in public/img/. Tutti i 4
partner logo trovati nel Figma. PNG foto convertite a JPG q85 per
ridurre da 99 MB a 3.4 MB totali.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:57:39 +02:00
177 changed files with 7155 additions and 3798 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
data
uploads
.env
.git
docs
assets-src
tests
*.md
+14 -5
View File
@@ -1,8 +1,17 @@
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user
SMTP_PASS=pass
# SMTP Aruba — il dominio insanitylab.it invia la posta via Aruba (SPF: include:aruba.it)
# Server posta in uscita autenticato: smtps.aruba.it:465 (SSL). Verificato funzionante.
SMTP_HOST=smtps.aruba.it
SMTP_PORT=465
# Casella reale del dominio usata per l'invio autenticato (con la sua password Aruba)
SMTP_USER=postmaster@insanitylab.it
SMTP_PASS=la-password-della-casella
# Aruba impone che il mittente sia la casella autenticata: uguale a SMTP_USER
CONTACT_FROM=postmaster@insanitylab.it
# Dove arrivano i messaggi del form
CONTACT_TO=info@insanitylab.it
CONTACT_FROM=sito@insanitylab.it
DB_PATH=data/insanitylab.db
UPLOADS_DIR=uploads
# Google Tag Manager: ID container preso da tagmanager.google.com (accanto al nome container).
# Formato GTM-XXXXXXX. Lascia vuoto per non caricare GTM (es. in locale/staging).
GTM_ID=GTM-XXXXXXX
+247
View File
@@ -0,0 +1,247 @@
# Task 2 Report — Estrazione e mappatura asset immagine da Figma
**Data:** 2026-07-01
**Fonte:** `../FigmaDoc/Web Insanity.fig` (60 immagini uniche + ~66 duplicati = 126 estratte)
**Destinazione:** `src/assets/img/` (31 file) + `public/img/` (2 loghi)
---
## Mappature completate
### Hero
| File destinazione | Hash sorgente | Descrizione |
|---|---|---|
| `hero-1.jpg` | `8b71f158...` | Uomo con palla medica (medicine ball) che la tiene verso donna in posizione crunch, palestra scura — "uomo con palla medica + donna" ✓ |
### Info sezioni (PERFORMANCE / BALANCE / LONGEVITY)
| File destinazione | Hash sorgente | Descrizione |
|---|---|---|
| `info-performance.jpg` | `0f803439...` | Uomo muscoloso in planche su sfondo grigio neutro |
| `info-balance.jpg` | `432d397a...` | Due donne sedute schiena a schiena in abiti sportivi beige/neutri |
| `info-longevity.jpg` | `d7fcf0aa...` | Donna anziana in posizione push-up — longevità attiva |
### Feature sezioni (immagini più grandi per ciascun pilastro)
| File destinazione | Hash sorgente | Descrizione |
|---|---|---|
| `feature-performance.jpg` | `6d602573...` | Uomo con chalk sulle mani — preparazione sollevamento pesi, performance atletica |
| `feature-balance.jpg` | `16e611de...` | Gruppo yoga all'aperto al tramonto — equilibrio e benessere |
| `feature-longevity.jpg` | `98f1f8d2...` | Donna anziana in piedi con bottiglia d'acqua, outfit sportivo beige — longevità sana |
### Quote background
| File destinazione | Hash sorgente | Descrizione |
|---|---|---|
| `quote-bg.jpg` | `28e8157d...` | Scritta "PERFORMANCE. BALANCE. LONGEVITY." su muro di intonaco — elemento visivo identitario |
### Training programs
| File destinazione | Hash sorgente | Descrizione |
|---|---|---|
| `training-one-to-one.jpg` | `85808a0c...` | Trainer e cliente a colloquio in palestra, tablet in mano — consulenza personalizzata |
| `training-small-groups.jpg` | `bb632b8d...` | Due donne con manubri leggeri in posizione atletica — small group training |
| `training-performance-class.jpg` | `91aa82b5...` | Grande gruppo in sala fitness/danza con palle fitball — performance class |
| `training-fit-remote.jpg` | `86c1363b...` | Donna a casa sul tappetino con laptop — allenamento remoto online |
### Team Trainer (homepage — 4 ritratti luce dorata)
| File destinazione | Hash sorgente | Persona identificata |
|---|---|---|
| `team-donata.jpg` | `0b6cf694...` | Donna capelli lunghi scuri dritti, luce dorada laterale — Donata ✓ |
| `team-nicola.jpg` | `1cc33c41...` | Uomo barba folta, turtleneck scuro, luce dorata — Nicola ✓ |
| `team-eleonora.jpg` | `a75ca105...` | Donna capelli biondi/chiari, stessa luce dorata — Eleonora ✓ |
| `team-antonello.jpg` | `e3c84f99...` | Uomo occhiali tondi, capelli rasatissimi, luce dorata — Antonello ✓ |
### Il Metodo (4 passi circolari)
| File destinazione | Hash sorgente | Step identificato |
|---|---|---|
| `method-step-1.jpg` | `88cfcd5f...` | Calibro plicometro su addome — valutazione composizione corporea |
| `method-step-2.jpg` | `d21671bb...` | Donna in cucina con verdure fresche e metro — pianificazione nutrizionale |
| `method-step-3.jpg` | `a3e84d7d...` | Silhouette uomo in palestra in controluce — sessione allenamento |
| `method-step-4.jpg` | `e50b6f78...` | Scarpe su tapis roulant — monitoraggio continuo e progressione |
### Partner loghi
| File destinazione | Hash sorgente | Logo identificato |
|---|---|---|
| `partner-edenred.png` | `bee22f37...` | "Ticket Welfare Edenred" — logo rosso/blu su sfondo trasparente ✓ |
| `partner-welbee.png` | `cf079250...` | "welbee" — logo verde su sfondo bianco ✓ |
| `partner-aon.png` | `014a7708...` | "Aon" — logo rosso su sfondo bianco ✓ |
| `partner-howden.png` | `872c3679...` | "HOWDEN" — logo blu su sfondo bianco ✓ |
### Loghi InsanityLab (public/img/)
| File destinazione | Sorgente | Note |
|---|---|---|
| `logo-dark.png` | `../Dati/logo-dark.png` | **Fallback** — logo non presente nel file Figma |
| `logo-light.png` | `../Dati/logo-light.png` | **Fallback** — logo non presente nel file Figma |
### About page (bonus — non richiesti dal brief)
| File destinazione | Hash sorgente | Descrizione |
|---|---|---|
| `about-exterior.jpg` | `8ffc5f7a...` | Facciata esterna palestra/studio InsanityLab, sera |
| `about-team-group.jpg` | `32ecd00a...` | Foto di gruppo team InsanityLab in bianco e nero |
| `about-training-group.jpg` | `d4608b10...` | Gruppo che salta con kettlebell in sala beige |
| `about-trainer-1.jpg` | `dafcc193...` | Trainer donna capelli scuri ondulati, luce dorata |
| `about-trainer-2.jpg` | `9c50f1d3...` | Trainer donna capelli scuri più corti, matura, luce dorata |
| `about-trainer-3.jpg` | `c51a49b9...` | Trainer donna capelli neri lisci, sorriso, sfondo chiaro |
| `about-trainer-4.jpg` | `3341a11b...` | Trainer uomo giovane senza barba, turtleneck scuro |
---
## Immagini attese ma non trovate nel Figma
| Nome atteso | Motivo mancanza | Azione intrapresa |
|---|---|---|
| `logo-dark.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-dark.png` ✓ |
| `logo-light.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-light.png` ✓ |
Tutti e 4 i loghi partner sono stati trovati nel Figma. Nessuna altra immagine richiesta dal brief risulta mancante.
---
## Dimensioni finali
```
src/assets/img/ 3.7 MB (31 file JPG + 4 PNG partner loghi)
public/img/ ~3.4 KB (2 PNG logo: logo-dark.png 1.7 KB, logo-light.png 1.7 KB)
```
Tutte le immagini sono ≤ 1400 px larghezza e ≤ 1600 px altezza. Il file più pesante è quote-bg.jpg a 460 KB. Nessun file supera 500 KB.
*Aggiornato dopo fix round 2 — i numeri precedenti (3.4 MB / nessun file >400 KB) erano errati: la pipeline originale non vincolava l'altezza, lasciando immagini portrait a 2-3 MB ciascuna.*
---
## Note tecnica — PNG → JPG
Le foto trainer e training erano estratte dal Figma come PNG lossless (68 MB ciascuna). Convertite a JPG qualità 85 con resize a 1200 px max → risparmio ~98% per file. I loghi partner restano PNG per preservare sfondo trasparente.
Il file `asset-map.json` referenzia gli hash originali (estensione .png/.jpg come estratti). I file di destinazione finali hanno tutti estensione .jpg (eccetto loghi partner .png), il che diverge dai nomi originali nel map ma i file in `src/assets/img/` riflettono il formato convertito.
---
## Conteggio
- **Mappate e presenti:** 33 (31 in src/assets/img + 2 in public/img)
- **Mancanti nel Figma:** 2 (logo-dark, logo-light) → risolti con fallback
- **Immagini estratte totali:** 126 (inclusi ~66 duplicati)
- **Lasciate in assets-src/ (gitignored):** 126 file sorgente
---
## Fix round 1
**Data:** 2026-07-02
### Cosa cambiato
1. **`scripts/asset-map.json`** — rimossa sezione `"public/img"` (chiavi non-hash, rompeva pipeline dopo `extract-fig-images.sh`). Aggiunta sezione top-level `"fallbacks"` con struttura `{ "public/img": { "../Dati/logo-dark.png": "logo-dark.png", "../Dati/logo-light.png": "logo-light.png" } }`. Le destinazioni in `src/assets/img` mantengono l'estensione REALE del sorgente estratto da Figma (`.png` per PNG Figma, `.jpg` per JPEG già presenti in assets-src).
2. **`scripts/apply-asset-map.mjs`** — aggiunto commento d'intestazione sull'ordine pipeline (extract → apply → optimize). Aggiunto loop `fallbacks`: copia da path relativo alla root del repo invece che da `assets-src/`, senza exit 1 per file assenti nel .fig.
3. **`scripts/optimize-assets.sh`** (nuovo) — bash, `set -euo pipefail`. Converte tutti i PNG non-partner in `src/assets/img/` → JPG qualità 85 con resize `1400x>`, poi ridimensiona JPG già esistenti se larghezza > 1400px. Idempotente (nullglob su `.png`).
### Output verifica riproducibilità
```
node scripts/apply-asset-map.mjs → "Copiate 33 immagini"
./scripts/optimize-assets.sh → 16 PNG→JPG, 5 JPG ridimensionati (hero-1, info-balance, info-longevity, method-step-1, method-step-2)
git status --short → elenco file identico al pre-fix (31 img + 4 PNG partner)
18 file binari rigenerati (ricompressione diversa da conversione manuale originale)
→ ricommittati: repo = output pipeline ✓
```
### Esito punto 4 — bassa risoluzione
- **`method-step-3.jpg`** (184×276): sorgente in `assets-src/a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg` è già 184×276 — stessa risoluzione del file committato. Nessuna versione ad alta risoluzione disponibile in `assets-src/`. **Richiede sorgente migliore dal cliente.**
- **`about-team-group.jpg`** (270×480): sorgente in `assets-src/32ecd00a20321e58c700fbaf739e53ac3bf03876.png` è già 270×480. Nessuna versione ad alta risoluzione disponibile. **Richiede sorgente migliore dal cliente.**
---
## Fix round 2
**Data:** 2026-07-02
### Numeri finali
```
src/assets/img/ 3.7 MB totale (31 JPG + 4 PNG partner)
```
Top-5 per peso:
| File | Dimensioni | Peso |
|---|---|---|
| quote-bg.jpg | 1087×1600 | 460 KB |
| info-performance.jpg | 1200×1500 | 256 KB |
| info-longevity.jpg | 1095×1600 | 248 KB |
| training-performance-class.jpg | 1400×1400 | 240 KB |
| about-exterior.jpg | 1400×781 | 188 KB |
Nessun file supera 500 KB. Precedente info-longevity.jpg era 2.7 MB, info-balance.jpg 2.0 MB.
### Cosa cambiato
1. **`scripts/optimize-assets.sh`** — vincolo resize cambiato da `'1400x>'` (solo larghezza) a `'1400x1600>'` (larghezza E altezza). Qualità JPG da 85 a 83. Tutti i JPG già esistenti vengono ri-codificati a q83 con nuovo vincolo (loop separato, skip file appena convertiti per evitare doppia codifica). Risultato: portrait da 2-3 MB → 248-460 KB.
2. **`scripts/asset-map.json`** — aggiunta chiave `"_nota"` in testa per documentare la conversione PNG→JPG operata da optimize-assets.sh (JSON non supporta commenti, chiave dedicata con prefisso `_`).
3. **`scripts/apply-asset-map.mjs`** — aggiunto `destDir.startsWith('_')` nella condizione di skip, così le chiavi meta `_*` del JSON vengono ignorate nella pipeline.
### Esito idempotenza
Terza esecuzione `apply-asset-map.mjs + optimize-assets.sh`: md5sum su tutti i .jpg identico prima e dopo → **IDEMPOTENTE**
### Esito verifica loghi
`public/img/logo-dark.png` (130×19, 1725 B) e `logo-light.png` (135×19, 1701 B) sono **identici** ai file sorgente in `../Dati/logo-dark.png` e `../Dati/logo-light.png` (stessa dimensione byte-level). Non sono placeholder: sono i loghi reali InsanityLab copiati dalla sezione `fallbacks` di asset-map.json tramite apply-asset-map.mjs. Le dimensioni in pixel ridotte (130×19 px) sono caratteristiche del PNG sorgente in Dati/, non di un errore di pipeline.
---
## Fix round 3
**Data:** 2026-07-02
### Problema chiuso
`public/img/logo-dark.png` e `logo-light.png` contenevano i loghi del template "PROWESS++" copiati da `../Dati/` (sorgente errato). Sostituiti con i loghi reali "insanitylab" estratti vettorialmente dal PDF Figma.
### Metodo
Sorgente: `../FigmaDoc/Homepage.pdf` (1334×10460 pt, 1 pagina).
Render esplorativi a 72150 dpi per individuare le zone logo:
- **Logo dark** (header, fondo bianco): riga nav in alto, x≈25pt, y≈10pt
- **Logo light** (footer, fondo scuro `srgb(58,41,41)`): sezione footer, y≈8490pt
Estrazione finale con `pdftoppm -r 300`, poi ImageMagick:
- Dark: `-fuzz 8% -transparent white -trim -resize 600x -strip`
- Light: `-crop 850x660+130+60` (esclude tagline e colonne destra), `-fuzz 12% -transparent 'srgb(58,41,41)' -trim -resize 600x -strip`
`-strip` su entrambi garantisce idempotenza md5sum tra esecuzioni successive.
### Coordinate pdftoppm a 300 dpi (pixel)
| Logo | -x | -y | -W | -H | crop interno |
|---|---|---|---|---|---|
| dark | 104 | 41 | 895 | 250 | — |
| light | 0 | 35375 | 1800 | 833 | `850x660+130+60` |
### Dimensioni finali
| File | Dimensioni | Tipo |
|---|---|---|
| `public/img/logo-dark.png` | **600×108 px** | TrueColorAlpha (PNG trasparente) |
| `public/img/logo-light.png` | **600×342 px** | TrueColorAlpha (PNG trasparente) |
### Aggiornamenti pipeline
- Creato `scripts/extract-logos.sh` (bash, `set -euo pipefail`, coordinate fisse, idempotente)
- `scripts/asset-map.json`: rimossa sezione `fallbacks` (loghi ora da extract-logos.sh, non da `../Dati`)
- `scripts/apply-asset-map.mjs`: commento pipeline aggiornato a step 4
- Ordine pipeline: `extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh`
### Verifica visiva
- `logo-dark.png`: icona onda/battito cardiaco beige + scritta "insanitylab" scura, layout orizzontale, sfondo trasparente. **Nessun residuo PROWESS++.**
- `logo-light.png`: icona onda/battito cardiaco beige sopra, scritta "insanitylab" beige sotto, layout stacked, sfondo trasparente. **Nessun residuo PROWESS++.**
### Idempotenza
```
md5sum run1 = md5sum run2 → IDEMPOTENTE ✓
```
+23
View File
@@ -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"]
+107 -90
View File
@@ -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 richtext, 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 (multistage, `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 antispam, ratelimit 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 unatantum, cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione 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.
+22 -3
View File
@@ -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' },
},
});
+40
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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).
+967 -52
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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",
+4
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Disallow: /admin
Disallow: /api
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
+42
View File
@@ -0,0 +1,42 @@
// Pipeline asset: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
// 1. extract-fig-images.sh: estrae hash-named images da .fig → assets-src/
// 2. apply-asset-map.mjs: copia da assets-src/ → destinazioni (src/assets/img, public/img)
// 3. optimize-assets.sh: converte PNG fotografici → JPG q85, ridimensiona ≤1400px
// 4. extract-logos.sh: estrae loghi insanitylab da FigmaDoc/Homepage.pdf → public/img/logo-{dark,light}.png
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
let n = 0, missing = 0;
// Sezione principale: copia da assets-src/
for (const [destDir, entries] of Object.entries(map)) {
if (destDir.startsWith('_') || destDir === 'fallbacks') continue;
mkdirSync(join(root, destDir), { recursive: true });
for (const [src, dest] of Object.entries(entries)) {
const from = join(root, 'assets-src', src);
if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
copyFileSync(from, join(root, destDir, dest));
n++;
}
}
// Sezione fallbacks: copia da path relativo alla root del repo (es. ../Dati/)
if (map.fallbacks) {
for (const [destDir, entries] of Object.entries(map.fallbacks)) {
mkdirSync(join(root, destDir), { recursive: true });
for (const [srcRelPath, dest] of Object.entries(entries)) {
const from = join(root, srcRelPath);
if (!existsSync(from)) { console.error(`MANCANTE (fallback): ${srcRelPath}`); missing++; continue; }
copyFileSync(from, join(root, destDir, dest));
n++;
}
}
}
console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
process.exit(missing ? 1 : 0);
+37
View File
@@ -0,0 +1,37 @@
{
"_nota": "Le destinazioni .png fotografiche vengono convertite in .jpg da optimize-assets.sh; il nome finale su disco ha estensione .jpg",
"src/assets/img": {
"8b71f158c30a0a23cc5113bfe1cd9a6c4f19e4c4.jpg": "hero-1.jpg",
"0f8034394657985cc4cf35eb8441ae93a7ce5302.jpg": "info-performance.jpg",
"432d397a342b4e8312a3a13a4fea836b3e867a64.jpg": "info-balance.jpg",
"d7fcf0aa827ec50f660145c0d319049b9153fefb.jpg": "info-longevity.jpg",
"6d6025730c3d0b73c0052fa67b1bb111c8462a08.jpg": "feature-performance.jpg",
"16e611de84f51bf5d01486a5e74ba0f8249850be.jpg": "feature-balance.jpg",
"98f1f8d257dfeffbaf35507d033e5bb51e08895e.png": "feature-longevity.png",
"28e8157dc88a42cffdbcd028e4cb02a6eb3de337.png": "quote-bg.png",
"85808a0c9aeb461c70c059b013eab47faa4fc4f1.png": "training-one-to-one.png",
"bb632b8da5e9e4d4b05207f04a8883163caac53a.png": "training-small-groups.png",
"91aa82b5dedee8ad56c6db249c9947c7d6d9c89a.png": "training-performance-class.png",
"86c1363b22a8fb3bb7b2bf044e52856d474d927e.jpg": "training-fit-remote.jpg",
"0b6cf69438af21bbca934b0b27995ac4fa967451.png": "team-donata.png",
"1cc33c41368d47c30d949e991c5a442ff51d1a91.png": "team-nicola.png",
"a75ca105e362e3c3bc5cfe0bfc7c39e69645660d.png": "team-eleonora.png",
"e3c84f9989df8b160d185bd40b774e460a4f6896.png": "team-antonello.png",
"88cfcd5fd321b5ca6e4833e3980b792a95090545.jpg": "method-step-1.jpg",
"d21671bb08f4f2eadde09f91a46b568788a209d7.jpg": "method-step-2.jpg",
"a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg": "method-step-3.jpg",
"e50b6f7818b3742ddba0f4ad4a162a8aafc505e0.jpg": "method-step-4.jpg",
"bee22f37bba6f18d3defcd47762375e1c8ddd0f5.png": "partner-edenred.png",
"cf079250ea1db7f0055b76af21e04b1ed1870770.png": "partner-welbee.png",
"872c3679df24bd0d3fdca7c4cb9a24d1836309a0.png": "partner-howden.png",
"014a7708ad61b12c5765ad4a60539d2fa325f9aa.png": "partner-aon.png",
"8ffc5f7a2428b0d0eabd09f676ab98d3e5644f0d.png": "about-exterior.png",
"32ecd00a20321e58c700fbaf739e53ac3bf03876.png": "about-team-group.png",
"d4608b102eb67ac067e6585250b3c37963db1d34.png": "about-training-group.png",
"dafcc19362e3a76dd20e9a656499a8068a3a9fca.png": "about-trainer-1.png",
"9c50f1d3b3a0477e80e03c0849d3d4687610b461.png": "about-trainer-2.png",
"c51a49b9be7f1b1a31de6b2f5261c8c5b5fdab52.png": "about-trainer-3.png",
"3341a11b7153349a23c75b1fa2a9a62d48953330.png": "about-trainer-4.png"
},
"_loghi": "logo-dark.png e logo-light.png estratti da extract-logos.sh (FigmaDoc/Homepage.pdf), non da ../Dati"
}
+28
View File
@@ -0,0 +1,28 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const [, , username, password, role = 'admin'] = process.argv;
if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1);
}
if (!['admin', 'superuser', 'user'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
process.exit(1);
}
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);`);
db.prepare(
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role`
).run(username, bcrypt.hashSync(password, 12), role);
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
FIG="../FigmaDoc/Web Insanity.fig"
OUT="assets-src"
rm -rf "$OUT" && mkdir -p "$OUT"
unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
cd "$OUT"
for f in *; do
case "$(file --brief --mime-type "$f")" in
image/png) mv "$f" "$f.png" ;;
image/jpeg) mv "$f" "$f.jpg" ;;
image/webp) mv "$f" "$f.webp" ;;
*) rm -f "$f" ;;
esac
done
echo "Estratte: $(ls | wc -l) immagini in $OUT/"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# extract-logos.sh — estrae loghi insanitylab dal PDF Figma Homepage
# Pipeline: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
# Idempotente: stessi file ad ogni esecuzione, coordinate fisse.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PDF="$ROOT/../FigmaDoc/Homepage.pdf"
OUT="$ROOT/public/img"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -f "$PDF" ]]; then
echo "ERRORE: PDF non trovato: $PDF" >&2
exit 1
fi
mkdir -p "$OUT"
echo "Estrazione loghi da $PDF..."
# ── Logo DARK (header, su fondo bianco) ─────────────────────────────────────
# Zona: x=104, y=41, W=895, H=250 px a 300dpi (pt: x≈25, y≈10, W≈215, H≈60)
pdftoppm -png -r 300 -f 1 -l 1 \
-x 104 -y 41 -W 895 -H 250 \
"$PDF" "$WORK/hdr"
convert "$WORK/hdr-1.png" \
-fuzz 8% -transparent white \
-trim +repage \
-resize 600x \
-strip \
"$OUT/logo-dark.png"
# ── Logo LIGHT (footer, su fondo scuro #3A2929) ─────────────────────────────
# Zona: x=0, y=35375, W=1800, H=833 px a 300dpi (pt: x≈0, y≈8490, W≈432, H≈200)
# poi crop interno: x=130, y=60, W=850, H=660 per includere solo icona+testo
pdftoppm -png -r 300 -f 1 -l 1 \
-x 0 -y 35375 -W 1800 -H 833 \
"$PDF" "$WORK/ftr"
convert "$WORK/ftr-1.png" \
-crop 850x660+130+60 +repage \
-fuzz 12% -transparent 'srgb(58,41,41)' \
-trim +repage \
-resize 600x \
-strip \
"$OUT/logo-light.png"
echo "Logo dark: $OUT/logo-dark.png $(identify -format '%wx%h' "$OUT/logo-dark.png")"
echo "Logo light: $OUT/logo-light.png $(identify -format '%wx%h' "$OUT/logo-light.png")"
echo "Done."
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Passo 3 della pipeline asset (dopo apply-asset-map.mjs).
# Converte PNG fotografici in src/assets/img/ → JPG q83 e ridimensiona a ≤1400x1600px.
# I PNG partner-* (loghi con trasparenza) restano PNG.
# Tutti i JPG esistenti vengono ri-codificati a q83 con vincolo 1400x1600>
# (larghezza ≤1400 px E altezza ≤1600 px, aspect ratio preservato).
# Idempotente se abbinato a apply-asset-map.mjs (che ripristina i sorgenti originali).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IMG_DIR="$ROOT/src/assets/img"
cd "$IMG_DIR"
# --- PNG fotografici → JPG q83 + resize ≤1400x1600 ---
shopt -s nullglob
declare -A just_converted
converted=0
for png in *.png; do
[[ "$png" == partner-* ]] && continue
jpg="${png%.png}.jpg"
convert "$png" -quality 83 -resize '1400x1600>' "$jpg"
rm "$png"
echo " PNG→JPG: $png$jpg"
just_converted["$jpg"]=1
(( converted++ )) || true
done
# --- Ri-codifica JPG esistenti a q83 + vincolo 1400x1600 ---
# Skip file appena convertiti (già a q83), elabora tutti gli altri.
reencoded=0
for jpg in *.jpg; do
[[ -n "${just_converted[$jpg]+x}" ]] && continue
mogrify -quality 83 -resize '1400x1600>' "$jpg"
echo " Ricodificato: $jpg"
(( reencoded++ )) || true
done
echo "Ottimizzazione completata: ${converted} PNG→JPG, ${reencoded} JPG ricodificati (q83, ≤1400x1600)."
+51
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+55
View File
@@ -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>
+58
View File
@@ -0,0 +1,58 @@
---
import { t } from '../lib/content';
import T from './content/T.astro';
interface Props { variant?: 'full' | 'footer' }
const { variant = 'full' } = Astro.props;
---
<form
class:list={['cform', `cform--${variant}`]}
method="post"
action="/api/contact"
novalidate
data-msg-success={t('form.msg.success')}
data-msg-error-generic={t('form.msg.error-generic')}
data-msg-error-network={t('form.msg.error-network')}
>
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
<input class="field" name="lastName" placeholder={t('form.field.last-name')} required maxlength="100" />
<input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
<p class="form-msg" hidden></p>
</form>
<style>
.cform__privacy { font-size: .74rem; opacity: .8; margin: 8px 0 0; line-height: 1.5; }
.cform__privacy a { text-decoration: underline; }
</style>
<script>
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
const btn = form.querySelector<HTMLButtonElement>('button')!;
btn.disabled = true;
msg.hidden = true;
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const data = await res.json();
msg.textContent = res.ok ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
if (res.ok) form.reset();
} catch {
msg.textContent = form.dataset.msgErrorNetwork!;
msg.className = 'form-msg form-msg--err';
}
msg.hidden = false;
btn.disabled = false;
});
});
</script>
+54
View File
@@ -0,0 +1,54 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro';
const year = new Date().getFullYear();
---
<footer class="ftr">
<div class="container ftr__grid">
<div>
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
<T tag="footer.tagline" as="p" class="ftr__tag" />
<T tag="footer.about" as="p" />
<p>{t('global.contact.address')}, {t('global.contact.city-line')}<br />{t('global.contact.phone')}<br /><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
<p class="ftr__hours">{t('global.hours.1')}</p>
<p class="ftr__hours">{t('global.hours.2')}</p>
<p class="ftr__legal">IN-SANITY LAB S.R.L. — P. IVA 03927341200</p>
</div>
<nav aria-label="training">
<T tag="footer.col.training-title" as="h4" />
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
</nav>
<nav aria-label="servizi">
<T tag="footer.col.services-title" as="h4" />
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav>
</div>
<div class="ftr__bar">
<div class="container ftr__bar-in">
<p>© {year} <T tag="footer.copyright" as="span" /> <span class="ftr__sep">·</span> <a href="/privacy">Privacy Policy</a> <span class="ftr__sep">·</span> <a href="/cookie-policy">Cookie Policy</a></p>
<p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div>
</div>
</footer>
<style>
.ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
.ftr a:hover { color: #fff; }
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
.ftr__grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr; gap: 40px; padding-block: 26px; text-align: center; align-items: start; }
/* Il logo è display:block (reset globale): lo centro nella colonna */
.ftr__grid img { margin-inline: auto; }
/* Training e Servizi allineati alla riga "Promuoviamo il movimento..." (logo+tagline ~110px) */
.ftr__grid nav { padding-top: 110px; }
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
.ftr__hours { margin: 0; }
.ftr__legal { margin-top: 14px; font-size: .78rem; opacity: .8; }
.ftr__bar-in .ftr__sep { margin: 0 4px; opacity: .5; }
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; }
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } .ftr__grid nav { padding-top: 0; } }
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
</style>
+95
View File
@@ -0,0 +1,95 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
interface Props {
user: { username: string; role: string } | null;
showTags: boolean;
canTags: boolean;
}
const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname;
const loginHref = `/login?next=${encodeURIComponent(path)}`;
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
---
<header class="hdr" id="site-header">
<div class="container hdr__in">
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => (
(!('adminOnly' in item && item.adminOnly) || user?.role === 'admin') && (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
)
))}
</nav>
<div class="hdr__user">
{user ? (
<details class="umenu">
<summary class="umenu__btn">{user.username} ▾</summary>
<div class="umenu__panel">
<a href={areaHref}>Area riservata</a>
{canTags && (
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
)}
<a href="/admin/logout">Esci</a>
</div>
</details>
) : (
<a class="hdr__login" href={loginHref}>Accedi</a>
)}
</div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</header>
<style>
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
.hdr__nav { display: flex; gap: 34px; }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@media (max-width: 991px) {
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
.hdr__nav.is-open { display: flex; }
.hdr__link { padding: 12px 0; }
.hdr__burger { display: block; }
}
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__login:hover { color: var(--c-accent-dark); }
.umenu { position: relative; }
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
.umenu__btn::-webkit-details-marker { display: none; }
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
</style>
<script>
const header = document.getElementById('site-header')!;
const toggle = document.getElementById('nav-toggle')!;
const nav = document.getElementById('site-nav')!;
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
toggle.addEventListener('click', () => {
const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
</script>
+25
View File
@@ -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>
+31
View File
@@ -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>
+136
View File
@@ -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>
+26
View File
@@ -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>
+11
View File
@@ -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>}
+16
View File
@@ -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} />}
+34
View File
@@ -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>
+72
View File
@@ -0,0 +1,72 @@
---
// Foto hero rimossa ma conservata su disco per riuso futuro: assets/img/hero-1.png
import T from '../content/T.astro';
import { t } from '../../lib/content';
const slides = [
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'] },
];
---
<section class="hero" aria-label="presentazione">
{slides.map((s, i) => (
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
<div class="container hero__in">
<div class="hero__text">
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/services">{t('home.hero.cta')}</a>
</div>
</div>
</div>
))}
{slides.length > 1 && (
<>
<button class="hero__arrow hero__arrow--prev" aria-label="precedente"></button>
<button class="hero__arrow hero__arrow--next" aria-label="successiva"></button>
</>
)}
</section>
<style>
.hero { position: relative; background:
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
.hero__slide { display: none; }
.hero__slide.is-active { display: block; }
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
testo sovrapposto sulla sinistra. */
.hero__in { position: relative; display: flex; align-items: center; min-height: 0; padding-block: 90px; }
/* Lo span .hero__line è reso dal componente <T> e non riceve il data-astro-cid
di questo componente: si ancora a .hero__text (che il cid ce l'ha) + :global. */
.hero__text h1 :global(.hero__line) { display: block; }
.hero__text { position: relative; z-index: 1; max-width: 640px; }
.hero__text h1 { font-weight: 500; }
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
@media (max-width: 991px) {
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
.hero__text { max-width: none; }
}
/* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
:global(html.js) .hero__text h1 :global(.hero__line) { opacity: 0; animation: hero-up .8s ease forwards; }
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(1)) { animation-delay: .15s; }
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(2)) { animation-delay: .3s; }
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(3)) { animation-delay: .45s; }
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
@media (prefers-reduced-motion: reduce) {
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn { opacity: 1; animation: none; }
}
</style>
<script>
const slides = document.querySelectorAll('.hero__slide');
if (slides.length > 1) {
let cur = 0;
const show = (i: number) => {
slides[cur].classList.remove('is-active');
cur = (i + slides.length) % slides.length;
slides[cur].classList.add('is-active');
};
document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
}
</script>
+32
View File
@@ -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>
+46
View File
@@ -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>
+19
View File
@@ -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>
+44
View File
@@ -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>
+40
View File
@@ -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>
+40
View File
@@ -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>
+50
View File
@@ -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>
+36
View File
@@ -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à lorario 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" },
];
+381
View File
@@ -0,0 +1,381 @@
import type { StyleGroup } from '../lib/style-presets';
import { site } from './site';
import { trainings } from './trainings';
import { team } from './team';
import { methodIntro, methodExtraNote, methodSteps } from './method';
import { partners } from './partners';
import { disciplines, days, slots } from './agenda';
import { services } from './services';
import { performanceClassPricing } from './pricing';
export type ContentType = 'text' | 'html' | 'image';
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style', 'align']): SeedEntry =>
({ tag, type: 'text', value, styleOptions });
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'align']): SeedEntry =>
({ tag, type: 'html', value, styleOptions });
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
export const contentSeed: SeedEntry[] = [
// --- Globali (site.ts) ---
T('global.contact.phone', site.phone, []),
T('global.contact.email', site.email, []),
T('global.contact.address', site.address, []),
T('global.contact.city-line', site.cityLine, []),
T('global.contact.map-query', site.mapQuery, []),
T('global.whatsapp', '351 753 5977', []),
T('global.hours.1', site.hours[0], []),
T('global.hours.2', site.hours[1], []),
T('global.nav.1.label', site.navigation[0].label, []),
T('global.nav.2.label', site.navigation[1].label, []),
T('global.nav.3.label', site.navigation[2].label, []),
T('global.nav.4.label', site.navigation[3].label, []),
T('global.nav.5.label', site.navigation[4].label, []),
T('global.nav.6.label', site.navigation[5].label, []),
T('global.nav.7.label', site.navigation[6].label, []),
// --- Header ---
T('header.logo.alt', 'InsanityLab', []),
I('header.logo.image'),
// --- Footer ---
I('footer.logo.image'),
H('footer.tagline', 'PERFORMANCE.&nbsp; BALANCE.&nbsp; 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', 'Lequilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.'),
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 unesperienza di allenamento completa e mirata.'),
// --- Home: team (data-driven da team.ts) ---
H('home.team.title', 'Team<br />Trainer'),
T('home.team.cta', 'Scopri team', []),
...team.flatMap((m) => {
const slug = m.shortName.toLowerCase();
return [
T(`team.${slug}.name`, m.name, []),
T(`team.${slug}.short-name`, m.shortName),
T(`team.${slug}.role`, m.role, []),
T(`team.${slug}.bio`, m.bio),
I(`team.${slug}.image`),
I(`team.${slug}.image-about`),
];
}),
// --- Home: metodo (data-driven da method.ts) ---
H('home.method.title', 'Il metodo<br />InsanityLab'),
T('home.method.cta', 'Contattaci', []),
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
T('method.extra-note', methodExtraNote, []),
...methodSteps.flatMap((s) => [
T(`method.step.${s.n}.title`, s.title),
T(`method.step.${s.n}.text`, s.text),
I(`method.step.${s.n}.image`),
]),
// --- Home: partner (data-driven da partners.ts) ---
...partners.flatMap((p, i) => [
T(`partner.${i + 1}.name`, p.name, []),
I(`partner.${i + 1}.image`),
]),
// --- Agenda (scope limitato: titolo, discipline, giorni, slot — celle escluse) ---
T('agenda.title', 'Agenda'),
...disciplines.map((d, i) => T(`agenda.discipline.${i + 1}`, d, [])),
...days.map((d, i) => T(`agenda.day.${i + 1}`, d, [])),
...slots.map((s, i) => T(`agenda.slot.${i + 1}`, s, [])),
// --- About: meta, hero ---
T('about.meta.title', 'Insanitylab', []),
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
T('about.hero.title', 'Insanitylab', []),
// --- About: mission ---
T('about.mission.title', 'Mission'),
T('about.mission.body', 'In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.'),
I('about.mission.image'),
// --- About: direzione + approccio ---
T('about.direzione.title', 'Direzione'),
T('about.direzione.body', 'Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.'),
T('about.direzione.cta', 'Scopri'),
T('about.approccio.title', 'Approccio'),
T('about.approccio.body', 'InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.'),
T('about.approccio.cta', 'Contattaci'),
// --- About: about ---
T('about.about.title', 'About'),
T('about.about.body', "InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."),
I('about.about.image'),
// --- About: convenzioni ---
T('about.convenzioni.title', 'Convenzioni'),
T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."),
T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'),
T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'),
T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."),
T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
I('about.convenzioni.image'),
// --- About: team ---
T('about.team.title', 'Team'),
T('about.team.body', 'Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.'),
T('about.team.staff-intro', 'Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:'),
// --- About: metodo ---
T('about.metodo.title', 'Metodo'),
T('about.metodo.body', 'Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.'),
T('about.metodo.closing', 'In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.'),
I('about.metodo.image'),
// --- About: studios ---
T('about.studios.title', 'In-Sanity Studios'),
T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."),
T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."),
T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'),
I('about.studios.image'),
// --- About: vision ---
T('about.vision.title', 'Vision'),
T('about.vision.body1', "InsanityLab nasce con l'obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l'estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita."),
T('about.vision.body2', "L'obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità."),
I('about.vision.image'),
// --- About: storylab + timeline (Timeline.astro) ---
T('about.storylab.title', 'Storylab'),
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."),
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
// --- Services: lista (/services) ---
T('services.meta.title', 'Servizi', []),
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []),
T('services.hero.title', 'Servizi', []),
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
T('services.card.more-label', 'Per saperne di più →', []),
// --- Services: dettaglio (data-driven da services.ts) ---
...services.flatMap((p) => [
T(`service.${p.slug}.title`, p.title),
T(`service.${p.slug}.subtitle`, p.subtitle),
T(`service.${p.slug}.excerpt`, p.excerpt),
T(`service.${p.slug}.long-description`, p.longDescription),
...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
...p.faq.flatMap((item, i) => [
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
]),
I(`service.${p.slug}.image`),
]),
// --- Services: dettaglio, template ---
T('service.side.categories-label', 'Categorie'),
T('service.side.category.1', 'Fitness', []),
T('service.side.category.2', 'Salute', []),
T('service.side.category.3', 'Stile di vita', []),
T('service.side.category.4', 'Nutrizione', []),
T('service.side.others-label', 'Altri servizi'),
H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
T('service.pricing.per', 'al mese, semestrale', []),
T('service.pricing.entries-singular', 'ingresso settimanale', []),
T('service.pricing.entries-plural', 'ingressi settimanali', []),
T('service.pricing.buy-cta', 'Acquista', []),
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
T('pricing.heading', performanceClassPricing.heading),
T('pricing.intro', performanceClassPricing.intro),
...performanceClassPricing.groups.flatMap((g, gi) => [
T(`pricing.group.${gi + 1}.label`, g.label),
...g.plans.flatMap((plan, pi) => [
T(`pricing.group.${gi + 1}.plan.${pi + 1}.note`, plan.note, []),
T(`pricing.group.${gi + 1}.plan.${pi + 1}.installments`, plan.installments, []),
T(`pricing.group.${gi + 1}.plan.${pi + 1}.best`, plan.best, []),
]),
]),
// --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []),
T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []),
T('training.hero.title', 'training', []),
T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'),
// --- Contact ---
T('contact.meta.title', 'Contatti', []),
T('contact.meta.description', 'Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.', []),
T('contact.hero.title', 'Contattaci', []),
I('contact.hero.image'),
T('contact.map.button', 'Carica la mappa (Google Maps)', []),
T('contact.map.note', 'La mappa viene caricata da Google solo dopo il tuo consenso.', []),
T('contact.info.address-label', 'Indirizzo'),
T('contact.info.phone-label', 'Telefono'),
T('contact.info.email-label', 'E-mail'),
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
// --- 404 ---
T('notfound.meta.title', 'Pagina non trovata', []),
T('notfound.eyebrow', 'Errore 404', []),
T('notfound.title', 'Pagina non trovata'),
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
T('notfound.cta', 'Torna alla home', []),
// --- Promo ed eventi (promo-eventi.astro) ---
// Titolo del banner PageHero (usato dalla variante promo-eventi2)
T('promo.pagehero.title', 'Promo ed eventi'),
// Hero
T('promo.hero.badge', 'insanity.lab · bologna savena'),
T('promo.hero.title1', 'Allenati con metodo.'),
T('promo.hero.accent', 'Scegli come'),
T('promo.hero.title2', 'vuoi farlo.'),
H('promo.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
T('promo.hero.cta', 'Le nostre proposte', []),
// Launch
T('promo.launch.badge', 'solo per apertura nuova sede Settembre 2026'),
T('promo.launch.title', 'Il nostro metodo nasce per durare.'),
T('promo.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
T('promo.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte."),
// Perché 5
T('promo.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
// Label bottone acquisto (condivisa dalle 4 card)
T('promo.buy-label', 'Acquista', []),
// Card 1 — One to One
T('promo.card.1.eyebrow', 'Personal training', []),
T('promo.card.1.title', 'One to One'),
T('promo.card.1.price', '163 €', []),
T('promo.card.1.price-old', '300 €', []),
T('promo.card.1.unit', '5 incontri', []),
T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'),
T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
// Card 2 — Small Group (in evidenza)
T('promo.card.2.badge', 'Più scelto', []),
T('promo.card.2.eyebrow', 'Personal training', []),
T('promo.card.2.title', 'Small Group'),
T('promo.card.2.price', '110 €', []),
T('promo.card.2.price-old', '220 €', []),
T('promo.card.2.unit', '5 incontri', []),
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('promo.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('promo.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
T('promo.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
// Card 3 — Reformer
T('promo.card.3.eyebrow', 'Pilates class', []),
T('promo.card.3.title', 'Reformer'),
T('promo.card.3.price', '100 €', []),
T('promo.card.3.price-old', '140 €', []),
T('promo.card.3.unit', '5 incontri', []),
T('promo.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
T('promo.card.3.feature.2', 'Lavoro a basso impatto sulle articolazioni'),
T('promo.card.3.feature.3', 'Percorso breve, non lezioni isolate'),
// Card 4 — Holistic class
T('promo.card.4.eyebrow', 'Holistic class', []),
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
T('promo.card.4.price', '50 €', []),
T('promo.card.4.price-old', '100 €', []),
T('promo.card.4.unit', '5 incontri', []),
T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
T('promo.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
T('promo.card.4.feature.3', 'Adatto a tutti i livelli'),
// Nota validità
T('promo.validity-note', 'Disponibile solo per l\'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
// FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp
T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'),
H('promo.faq.1.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi ci scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare le singole sessioni in base agli orari disponibili. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'),
H('promo.faq.2.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi vai nella schermata Home, clicca il servizio che hai acquistato e seleziona autonomamente la class nel giorno e nell\'orario che desideri. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'),
T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'),
H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Small Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'),
T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'),
H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('promo.faq.5.q', 'Il pagamento è sicuro?'),
H('promo.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+9
View File
@@ -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 dellallenamento personalizzato. A queste possono essere aggiunti servizi e approfondimenti extra, in base alle esigenze e agli obiettivi della persona.";
export const methodExtraNote = "Disponibili su richiesta, non incluse nel percorso standard.";
export const methodSteps = [
{ n: 1, title: "Prima consulenza conoscitiva iniziale", text: "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" },
];

Some files were not shown because too many files have changed in this diff Show More