Compare commits

...

14 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
16 changed files with 438 additions and 140 deletions
+4
View File
@@ -11,3 +11,7 @@ CONTACT_FROM=postmaster@insanitylab.it
CONTACT_TO=info@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
+2 -2
View File
@@ -14,8 +14,8 @@ const { variant = 'full' } = Astro.props;
data-msg-error-network={t('form.msg.error-network')}
>
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
{variant === 'full' && <input class="field" name="lastName" placeholder={t('form.field.last-name')} maxlength="100" />}
{variant === 'full' && <input class="field" name="phone" placeholder={t('form.field.phone')} maxlength="40" />}
<input class="field" 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" />
+6 -7
View File
@@ -1,6 +1,5 @@
---
import { site } from '../data/site';
import ContactForm from './ContactForm.astro';
import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro';
const year = new Date().getFullYear();
@@ -24,10 +23,6 @@ const year = new Date().getFullYear();
<T tag="footer.col.services-title" as="h4" />
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav>
<div>
<T tag="footer.col.contact-title" as="h4" />
<ContactForm variant="footer" />
</div>
</div>
<div class="ftr__bar">
<div class="container ftr__bar-in">
@@ -42,7 +37,11 @@ const year = new Date().getFullYear();
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
.ftr a:hover { color: #fff; }
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
.ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
.ftr__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; }
@@ -50,6 +49,6 @@ const year = new Date().getFullYear();
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.ftr__bar-in a { display: inline; margin-left: 12px; }
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 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>
+3 -1
View File
@@ -16,7 +16,9 @@ const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item, i) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
(!('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">
+5 -11
View File
@@ -1,10 +1,9 @@
---
import hero1 from '../../assets/img/hero-1.png';
// Foto hero rimossa ma conservata su disco per riuso futuro: assets/img/hero-1.png
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const slides = [
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'], image: hero1, alt: 'Atleti InsanityLab' },
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'] },
];
---
<section class="hero" aria-label="presentazione">
@@ -15,7 +14,6 @@ const slides = [
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/services">{t('home.hero.cta')}</a>
</div>
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
</div>
</div>
))}
@@ -34,32 +32,28 @@ const slides = [
.hero__slide.is-active { display: block; }
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
testo sovrapposto sulla sinistra. */
.hero__in { position: relative; display: flex; align-items: center; min-height: calc(100vh - var(--header-h)); }
.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: 46%; }
.hero__text { position: relative; z-index: 1; max-width: 640px; }
.hero__text h1 { font-weight: 500; }
.hero__img { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
@media (max-width: 991px) {
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
.hero__text { max-width: none; }
.hero__img { position: static; max-width: 100%; height: auto; }
}
/* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
@keyframes hero-side { from { opacity: 0; transform: translateX(60px); } to { opacity: 1; transform: none; } }
:global(html.js) .hero__text h1 :global(.hero__line) { opacity: 0; animation: hero-up .8s ease forwards; }
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(1)) { animation-delay: .15s; }
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(2)) { animation-delay: .3s; }
:global(html.js) .hero__text h1 :global(.hero__line:nth-child(3)) { animation-delay: .45s; }
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
@media (prefers-reduced-motion: reduce) {
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; }
:global(html.js) .hero__text h1 :global(.hero__line), :global(html.js) .hero__text .btn { opacity: 1; animation: none; }
}
</style>
+77
View File
@@ -24,6 +24,7 @@ export const contentSeed: SeedEntry[] = [
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, []),
@@ -32,6 +33,7 @@ export const contentSeed: SeedEntry[] = [
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', []),
@@ -299,6 +301,81 @@ export const contentSeed: SeedEntry[] = [
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]));
+2
View File
@@ -18,6 +18,8 @@ export const site = {
{ label: "Blog & Edugo", href: "/blog" },
{ label: "Promo ed eventi", href: "/promo-eventi" },
{ label: "Contatti", href: "/contact" },
// Pagina di test: visibile solo agli admin loggati, nascosta al pubblico.
{ label: "Promo 2 (test)", href: "/promo-eventi2", adminOnly: true },
],
footerTraining: [
{ label: "One to one", href: "/training#one-to-one" },
+10
View File
@@ -19,10 +19,17 @@ const user = tok ? getSessionUser(getDb(), tok) : null;
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
const showTags = canTags && wantTags;
// Google Tag Manager: l'ID container arriva da GTM_ID (.env), letto a runtime via process.env
// perché in SSR le PUBLIC_* di Vite verrebbero inlinate a build-time. Se vuoto GTM non carica.
const gtmId = process.env.GTM_ID;
---
<!doctype html>
<html lang="it">
<head>
{gtmId && (
<script is:inline set:html={`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtmId}');`} />
)}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title>
@@ -36,6 +43,9 @@ const showTags = canTags && wantTags;
<script is:inline>document.documentElement.classList.add('js');</script>
</head>
<body>
{gtmId && (
<noscript set:html={`<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
)}
<Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main>
<Footer />
+2
View File
@@ -24,6 +24,8 @@ export function validateContact(data: unknown):
};
if (value.website) return { ok: false, error: 'Richiesta non valida.' };
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
if (!value.lastName) return { ok: false, error: 'Il cognome è obbligatorio.' };
if (!value.phone) return { ok: false, error: 'Il telefono è obbligatorio.' };
if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
+19 -1
View File
@@ -91,7 +91,8 @@ export function createDb(path?: string): Database.Database {
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
// il nuovo valore è quello del seed corrente per lo stesso tag.
const OLD_SECTION_DEFAULTS: [string, string][] = [
['global.nav.3.label', 'Programmi'],
// La voce "Servizi" (ex "Programmi") è la 2ª del menu dopo il riordino (commit 5ff4771).
['global.nav.2.label', 'Programmi'],
['footer.col.services-title', 'Programmi'],
['home.services.title', 'Programmi'],
['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.'],
@@ -165,6 +166,23 @@ export function createDb(path?: string): Database.Database {
const updMetodoTx = db.transaction(() => { for (const [tag, oldV, newV] of METODO) upd02.run(newV, tag, oldV); });
updMetodoTx();
// Modifiche promo (commit 1522d97): la freccia "→" dei bottoni è passata al CSS (::after) e il
// maiuscolo a text-transform. Il seed è ora minuscolo e senza freccia. Le righe di prod ancora
// al vecchio default vanno allineate, altrimenti il CSS ::after produce una DOPPIA freccia.
// Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
const PROMO_FRECCE: [string, string, string][] = [
['promo.hero.cta', 'LE NOSTRE PROPOSTE →', 'Le nostre proposte'],
['promo.buy-label', 'ACQUISTA →', 'Acquista'],
];
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
updPromoTx();
// Fix: il titolo del servizio Performance Class era rimasto troncato a "Class" nei DB esistenti
// (valore vecchio mai allineato dal seed INSERT OR IGNORE), mostrando "CLASS" come H1 della
// pagina /services/performance-class e nelle card di /services e home. Guardato dal vecchio
// valore → idempotente, non tocca eventuali modifiche del pannello.
db.prepare("UPDATE content_blocks SET value = 'Performance Class' WHERE tag = 'service.performance-class.title' AND value = 'Class'").run();
// Modifiche 03: due ingressi dello stesso stabile → indirizzo esteso in footer e contatti.
// Il tag global.contact.map-query è nuovo (inserito dal seed); qui si aggiorna solo l'indirizzo
// già esistente, se non modificato dal pannello. Idempotente.
+61 -111
View File
@@ -1,142 +1,86 @@
---
import Base from '../layouts/Base.astro';
import T from '../components/content/T.astro';
import { t, resolveContent } from '../lib/content';
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account,
// inserisce i dati e paga via Stripe dentro la piattaforma. Link centralizzato qui:
// quando avremo gli ID membership per singolo prodotto basterà mappare BSPORT_URL card per card.
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
export const prerender = false;
// I testi sono editabili dal pannello contenuti (tag `promo.*`). Qui resta solo la
// struttura: quante card, quante feature ciascuna e quale è "in evidenza".
const cards = [
{ id: 1, features: 4, featured: false },
{ id: 2, features: 4, featured: true },
{ id: 3, features: 3, featured: false },
{ id: 4, features: 3, featured: false },
];
const faqs = [1, 2, 3, 4, 5, 6];
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
const wa = t('global.whatsapp');
---
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
<div class="lp">
<div class="page">
<section class="hero">
<div class="badge">insanity.lab · bologna savena</div>
<h1>Allenati con metodo.</h1>
<p class="accent">Scegli come</p>
<h1>vuoi farlo.</h1>
<p class="desc">Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.</p>
<a class="btn-sand" href="#proposte">LE NOSTRE PROPOSTE →</a>
<T tag="promo.hero.badge" as="div" class="badge" />
<T tag="promo.hero.title1" as="h1" />
<T tag="promo.hero.accent" as="p" class="accent" />
<T tag="promo.hero.title2" as="h1" />
<T tag="promo.hero.desc" as="p" class="desc" />
<a class="btn-sand" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
</section>
<section class="launch">
<div class="badge">solo per apertura nuova sede Settembre 2026</div>
<h1>Il nostro metodo nasce per durare.</h1>
<p class="accent">Per l'apertura, un'eccezione. Solo per poco.</p>
<p class="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.</p>
<T tag="promo.launch.badge" as="div" class="badge" />
<T tag="promo.launch.title" as="h1" />
<T tag="promo.launch.accent" as="p" class="accent" />
<T tag="promo.launch.desc" as="p" class="desc" />
</section>
<section class="why5">
<p>Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.</p>
<T tag="promo.why5.text" as="p" />
</section>
<section class="cards-wrap" id="proposte">
<div class="cards">
<div class="card">
<div class="eyebrow">Personal training</div>
<div class="title">One to One</div>
<div class="price-row">
<div class="price">163 €</div>
<div class="price-old">300 €</div>
{cards.map((c) => (
<div class:list={['card', c.featured && 'featured']}>
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
<T tag={`promo.card.${c.id}.title`} as="div" class="title" />
<div class="price-row">
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
</div>
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
<ul>
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
))}
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Screening iniziale, definizione dei tuoi obiettivi</li>
<li>Check-up con profilazione fisica, il tuo punto di partenza</li>
<li>PT dedicato, presente a ogni sessione</li>
<li>In-sanity plan, il tuo programma personalizzato</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
<div class="card featured">
<div class="featured-badge">Più scelto</div>
<div class="eyebrow">Personal training</div>
<div class="title">Small Group</div>
<div class="price-row">
<div class="price">110 €</div>
<div class="price-old">220 €</div>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Screening iniziale, definizione dei tuoi obiettivi</li>
<li>Check-up con profilazione fisica, il tuo punto di partenza</li>
<li>PT dedicato, presente a ogni sessione</li>
<li>In-sanity plan, il tuo programma personalizzato</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
<div class="card">
<div class="eyebrow">Pilates class</div>
<div class="title">Reformer</div>
<div class="price-row">
<div class="price">100 €</div>
<div class="price-old">140 €</div>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Core, postura e mobilità su macchina Reformer</li>
<li>Lavoro a basso impatto sulle articolazioni</li>
<li>Percorso breve, non lezioni isolate</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
<div class="card">
<div class="eyebrow">Holistic class</div>
<div class="title">Pilates Mat, Mobility, Yoga</div>
<div class="price-row">
<div class="price">50 €</div>
<div class="price-old">100 €</div>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Mobilità articolare, respirazione, equilibrio</li>
<li>Da solo, o integrato nei tuoi allenamenti</li>
<li>Adatto a tutti i livelli</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
))}
</div>
<p class="validity-note">Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026</p>
<T tag="promo.validity-note" as="p" class="validity-note" />
</section>
<section class="faq-wrap">
<h1>Tutto quello che vuoi sapere prima di acquistare.</h1>
<div class="faq-block">
<div class="faq-q"><p>Come funziona il pacchetto a incontri per One to One e Small Group?</p><div class="faq-toggle">+</div></div>
<p class="faq-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 (351 753 5977)</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.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Come funziona il percorso Reformer o Holistic Class?</p><div class="faq-toggle">+</div></div>
<p class="faq-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 (351 753 5977)</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Posso usare gli incontri su servizi diversi?</p><div class="faq-toggle">+</div></div>
<p class="faq-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.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Posso regalare un pacchetto a qualcun altro?</p><div class="faq-toggle">+</div></div>
<p class="faq-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 (351 753 5977)</span> per organizzare le sessioni.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Il pagamento è sicuro?</p><div class="faq-toggle">+</div></div>
<p class="faq-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.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Posso chiedere un rimborso?</p><div class="faq-toggle">+</div></div>
<p class="faq-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.</p>
</div>
<T tag="promo.faq.title" as="h1" />
{faqs.map((n) => {
const a = resolveContent(`promo.faq.${n}.a`);
return (
<div class="faq-block">
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
</div>
);
})}
</section>
</div>
@@ -163,7 +107,10 @@ export const prerender = false;
});
</script>
<style>
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
scope della pagina): con lo scope Astro il CSS non li raggiungerebbe. Ogni regola è
prefissata `.lp`, quindi resta confinata a questa landing senza toccare il resto del sito. -->
<style is:global>
.lp { background: #ECE7DC; }
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
@@ -182,6 +129,9 @@ export const prerender = false;
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
text-decoration: none;
}
/* La freccia è decorazione (il testo del tag non la include più) */
.lp .btn-sand::after { content: ' →'; }
.lp .card .buy::after { content: ' →'; }
/* LAUNCH */
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
+181
View File
@@ -0,0 +1,181 @@
---
import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro';
import T from '../components/content/T.astro';
import { t, resolveContent } from '../lib/content';
// Variante di test di /promo-eventi ristrutturata sui pattern del sito: banner PageHero
// a tutta larghezza, sezioni .section/.container su sfondo bianco, bottoni .btn globali,
// font e palette dal design system. Contenuti identici (stessi tag `promo.*`). Gli stili
// custom (griglia card, accordion FAQ) sono namespaced sotto `.promo2`.
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
export const prerender = false;
const cards = [
{ id: 1, features: 4, featured: false },
{ id: 2, features: 4, featured: true },
{ id: 3, features: 3, featured: false },
{ id: 4, features: 3, featured: false },
];
const faqs = [1, 2, 3, 4, 5, 6];
const wa = t('global.whatsapp');
---
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
<PageHero title={t('promo.pagehero.title')} eyebrow={t('promo.hero.badge')} />
<div class="promo2">
<!-- Claim principale -->
<section class="section claim-section">
<div class="claim-wrap">
<h2 class="claim">
<span class="l1"><T tag="promo.hero.title1" as="span" /></span>
<span class="l2"><em><T tag="promo.hero.accent" as="span" /></em> <T tag="promo.hero.title2" as="span" /></span>
</h2>
<T tag="promo.hero.desc" as="p" class="claim-desc" />
<a class="btn" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
</div>
</section>
<!-- Lancio + perché 5 -->
<section class="section section--alt">
<div class="container launch">
<T tag="promo.launch.badge" as="p" class="eyebrow" />
<h2><T tag="promo.launch.title" as="span" /></h2>
<p class="launch-accent"><T tag="promo.launch.accent" as="span" /></p>
<T tag="promo.launch.desc" as="p" />
<T tag="promo.why5.text" as="p" class="why5" />
</div>
</section>
<!-- Pacchetti -->
<section class="section" id="proposte">
<div class="container">
<div class="cards">
{cards.map((c) => (
<div class:list={['card', c.featured && 'featured']}>
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
<T tag={`promo.card.${c.id}.title`} as="div" class="ptitle" />
<div class="price-row">
<T tag={`promo.card.${c.id}.price`} as="div" class="price" />
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
</div>
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
<ul>
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
))}
</ul>
<a class:list={['btn', c.featured ? 'btn--dark' : 'btn--light', 'buy']} href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
</div>
))}
</div>
<T tag="promo.validity-note" as="p" class="validity-note" />
</div>
</section>
<!-- FAQ -->
<section class="section section--alt">
<div class="container faq-wrap">
<T tag="promo.faq.title" as="h2" class="faq-title" />
{faqs.map((n) => {
const a = resolveContent(`promo.faq.${n}.a`);
return (
<div class="faq-block">
<div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
</div>
);
})}
</div>
</section>
</div>
</Base>
<script>
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
document.querySelectorAll('.promo2 .faq-block .faq-q').forEach((q) => {
const block = q.closest('.faq-block');
const toggle = q.querySelector('.faq-toggle');
q.setAttribute('role', 'button');
q.setAttribute('tabindex', '0');
q.setAttribute('aria-expanded', 'false');
const set = (open: boolean) => {
block?.classList.toggle('is-open', open);
q.setAttribute('aria-expanded', String(open));
if (toggle) toggle.textContent = open ? '' : '+';
};
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
q.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
});
});
</script>
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
scope della pagina). Tutto è prefissato `.promo2`; la struttura usa le classi globali
del sito (.section, .container, .btn, .eyebrow, .section-heading). -->
<style is:global>
/* Claim principale */
.promo2 .claim-section { padding-block: 72px; text-align: center; }
.promo2 .claim-wrap { max-width: 820px; margin: 0 auto; padding-inline: 20px; }
.promo2 .claim { font-family: var(--font-heading); font-weight: 500; letter-spacing: .02em;
color: var(--c-heading); line-height: 1.18; font-size: clamp(2.1rem, 4.5vw, 3.1rem); margin: 0 0 22px; }
.promo2 .claim .l1, .promo2 .claim .l2 { display: block; }
.promo2 .claim em { font-style: italic; color: var(--c-accent-dark); }
.promo2 .claim-desc { max-width: 500px; margin: 0 auto 30px; font-size: 1.05rem; color: var(--c-text); }
/* Lancio — banda evidenziata */
.promo2 .launch { max-width: 760px; text-align: center; }
.promo2 .launch .eyebrow { display: block; margin: 0 0 14px; }
.promo2 .launch h2 { margin: 0 0 6px; }
.promo2 .launch-accent { font-family: var(--font-heading); font-style: italic; font-size: 1.2rem; color: var(--c-accent-dark); margin: 0 0 22px; }
.promo2 .launch > p { color: var(--c-text); margin-inline: auto; max-width: 620px; }
.promo2 .why5 { font-family: var(--font-heading); font-style: italic; font-size: 1.25rem; line-height: 1.65;
color: var(--c-heading); margin: 32px auto 0; max-width: 600px; padding-top: 28px; border-top: 2px solid var(--c-accent); }
/* Pacchetti */
.promo2 .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; align-items: stretch; }
.promo2 .card { background: var(--c-bg); border: 1px solid #e7e0d6; border-radius: 6px; padding: 34px 30px; position: relative; display: flex; flex-direction: column; transition: box-shadow .25s, transform .25s; }
.promo2 .card:hover { box-shadow: 0 14px 40px rgba(58,41,41,.10); transform: translateY(-3px); }
.promo2 .card.featured { border: 1px solid var(--c-accent); box-shadow: 0 10px 34px rgba(58,41,41,.08); }
.promo2 .featured-badge {
position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--c-accent);
color: #fff; font-family: var(--font-heading); font-size: 10.5px; font-weight: 600; letter-spacing: .12em;
text-transform: uppercase; padding: 6px 16px; border-radius: 999px; white-space: nowrap;
}
.promo2 .card .eyebrow { margin: 0 0 8px; font-size: .68rem; }
.promo2 .card .ptitle { font-family: var(--font-heading); font-size: 1.4rem; font-weight: 500; color: var(--c-heading); letter-spacing: .03em; margin: 0 0 18px; }
.promo2 .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 3px; }
.promo2 .card .price { font-family: var(--font-heading); font-size: 2.4rem; font-weight: 600; color: var(--c-heading); line-height: 1; }
.promo2 .card .price-old { font-size: 1.05rem; color: var(--c-text-light); text-decoration: line-through; }
.promo2 .card .unit { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--c-text-light); margin: 0 0 22px; }
.promo2 .card ul { list-style: none; padding: 22px 0 0; margin: 0 0 26px; border-top: 1px solid #f0eae1; display: flex; flex-direction: column; gap: 11px; }
.promo2 .card li { font-size: .9rem; line-height: 1.5; color: var(--c-text); display: flex; gap: 10px; }
.promo2 .card li::before { content: "—"; color: var(--c-accent); font-weight: 700; flex-shrink: 0; }
.promo2 .card .buy { margin-top: auto; justify-content: center; }
.promo2 .validity-note { text-align: center; font-size: .82rem; color: var(--c-text-light); margin: 34px auto 0; max-width: 560px; }
/* FAQ */
.promo2 .faq-wrap { max-width: 760px; }
.promo2 .faq-title { text-align: center; text-transform: uppercase; letter-spacing: .1em; font-size: clamp(1.5rem, 3vw, 2rem); margin: 0 0 44px; }
.promo2 .faq-block { border-top: 1px solid #e4ded6; padding: 20px 4px; transition: padding .2s; }
.promo2 .faq-block:last-child { border-bottom: 1px solid #e4ded6; }
.promo2 .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
.promo2 .faq-q p { margin: 0; font-family: var(--font-heading); font-size: 1rem; font-weight: 500; color: var(--c-heading); letter-spacing: .02em; }
.promo2 .faq-q:focus-visible { outline: 2px solid var(--c-accent-dark); outline-offset: 4px; }
.promo2 .faq-toggle {
width: 32px; height: 32px; border-radius: 50%; border: 1px solid #d8cfc2; display: flex;
align-items: center; justify-content: center; flex-shrink: 0; font-size: 17px; color: var(--c-heading);
transition: background .2s ease, color .2s ease, border-color .2s ease;
}
.promo2 .faq-block.is-open .faq-toggle { background: var(--c-accent); color: #fff; border-color: var(--c-accent); }
.promo2 .faq-a { display: none; margin: 14px 0 0; font-size: .94rem; line-height: 1.75; color: var(--c-text); max-width: 640px; }
.promo2 .faq-block.is-open .faq-a { display: block; }
.promo2 .faq-a .hl { color: var(--c-accent-dark); font-weight: 600; }
@media (max-width: 767px) {
.promo2 .cards { grid-template-columns: 1fr; }
}
</style>
+5 -3
View File
@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
import { validateContact } from '../src/lib/contact';
describe('validateContact', () => {
const good = { firstName: 'Mario', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
const good = { firstName: 'Mario', lastName: 'Rossi', phone: '3331234567', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
it('accetta payload valido e normalizza i campi opzionali', () => {
it('accetta payload valido', () => {
const r = validateContact(good);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.lastName).toBe('');
expect(r.value.lastName).toBe('Rossi');
expect(r.value.email).toBe('mario@example.com');
}
});
@@ -19,6 +19,8 @@ describe('validateContact', () => {
it('rifiuta campi obbligatori mancanti o vuoti', () => {
expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
expect(validateContact({ ...good, lastName: ' ' }).ok).toBe(false);
expect(validateContact({ ...good, phone: '' }).ok).toBe(false);
expect(validateContact({ ...good, message: '' }).ok).toBe(false);
expect(validateContact(null).ok).toBe(false);
});
+1 -1
View File
@@ -21,7 +21,7 @@ describe('applyContentUpdate', () => {
it('404 su tag inesistente, 400 su styles fuori whitelist', () => {
expect(applyContentUpdate(db, 'no.tag', { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 404 });
expect(applyContentUpdate(db, textTag, { styles: { size: 'xxl' } }, 'a')).toMatchObject({ ok: false, status: 400 });
expect(applyContentUpdate(db, textTag, { styles: { size: 'xxxl' } }, 'a')).toMatchObject({ ok: false, status: 400 });
});
it('sanifica i tag html e rifiuta value su tag image', () => {
+59 -2
View File
@@ -44,7 +44,7 @@ describe('schema content_blocks e ruoli', () => {
ins.run('footer.col.programs-title', 'Colonna modificata');
// Riga mai toccata dal pannello: valore identico al vecchio seed → deve prendere il nuovo default.
db1.prepare("INSERT INTO content_blocks (tag, type, value) VALUES ('programs.meta.title', 'text', 'Programmi')").run();
db1.prepare("UPDATE content_blocks SET value = 'Programmi' WHERE tag = 'global.nav.3.label'").run();
db1.prepare("UPDATE content_blocks SET value = 'Programmi' WHERE tag = 'global.nav.2.label'").run();
});
old();
db1.close();
@@ -60,8 +60,10 @@ describe('schema content_blocks e ruoli', () => {
expect(val('footer.col.services-title')).toMatchObject({ value: 'Colonna modificata', updated_by: 'editor' });
// Le righe rimaste al vecchio valore di default prendono il nuovo ("Servizi").
// Dopo il riordino del menu (commit 5ff4771) la voce "Servizi" è la 2ª: la migrazione
// aggiorna global.nav.2.label da 'Programmi' al seed corrente 'Servizi'.
expect(val('services.meta.title')).toMatchObject({ value: 'Servizi' });
expect(val('global.nav.3.label')).toMatchObject({ value: 'Servizi' });
expect(val('global.nav.2.label')).toMatchObject({ value: 'Servizi' });
// La riga rinominata ma modificata dal pannello resta invariata (già verificata sopra):
// home.services.title = 'Titolo modificato'.
@@ -82,6 +84,61 @@ describe('schema content_blocks e ruoli', () => {
}
});
it('allinea i bottoni promo col vecchio testo-freccia al seed senza freccia', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-promo-'));
const path = join(dir, 'test.db');
try {
// DB pre-refactor: valori con freccia letterale e maiuscolo (poi spostati nel CSS).
const db1 = createDb(path);
db1.prepare("UPDATE content_blocks SET value = 'LE NOSTRE PROPOSTE →' WHERE tag = 'promo.hero.cta'").run();
db1.prepare("UPDATE content_blocks SET value = 'ACQUISTA →' WHERE tag = 'promo.buy-label'").run();
// Riga modificata dal pannello: NON deve essere toccata.
db1.prepare("UPDATE content_blocks SET value = 'Compra ora', updated_by = 'editor' WHERE tag = 'promo.buy-label'").run();
db1.close();
// Riapertura: la migrazione allinea solo le righe rimaste al vecchio default con freccia.
const db2 = createDb(path);
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(val('promo.hero.cta')).toBe('Le nostre proposte');
// promo.buy-label era stato editato dal pannello → resta invariato.
expect(val('promo.buy-label')).toBe('Compra ora');
db2.close();
// Idempotente: terza apertura non altera.
const db3 = createDb(path);
expect((db3.prepare("SELECT value FROM content_blocks WHERE tag = 'promo.hero.cta'").get() as { value: string }).value).toBe('Le nostre proposte');
db3.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('corregge il titolo Performance Class rimasto troncato a "Class"', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-pc-'));
const path = join(dir, 'test.db');
try {
// DB con il vecchio valore troncato.
const db1 = createDb(path);
db1.prepare("UPDATE content_blocks SET value = 'Class', updated_by = NULL WHERE tag = 'service.performance-class.title'").run();
db1.close();
const db2 = createDb(path);
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(val('service.performance-class.title')).toBe('Performance Class');
db2.close();
// Non deve toccare un valore modificato dal pannello.
const db3 = createDb(path);
db3.prepare("UPDATE content_blocks SET value = 'Titolo mio', updated_by = 'admin' WHERE tag = 'service.performance-class.title'").run();
db3.close();
const db4 = createDb(path);
expect((db4.prepare("SELECT value FROM content_blocks WHERE tag = 'service.performance-class.title'").get() as { value: string }).value).toBe('Titolo mio');
db4.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('migra un DB esistente senza colonna role', () => {
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
+1 -1
View File
@@ -9,7 +9,7 @@ describe('style presets', () => {
it('rifiuta gruppo sconosciuto e valore fuori lista', () => {
expect(validateStyles({ font: 'comic' }).ok).toBe(false);
expect(validateStyles({ size: 'xxl' }).ok).toBe(false);
expect(validateStyles({ size: 'xxxl' }).ok).toBe(false);
expect(validateStyles('non-oggetto').ok).toBe(false);
});