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
15 changed files with 308 additions and 101 deletions
+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>
+7 -4
View File
@@ -33,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', []),
@@ -301,22 +302,24 @@ export const contentSeed: SeedEntry[] = [
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.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 →', []),
T('promo.hero.cta', 'Le nostre proposte', []),
// Launch
T('promo.launch.badge', 'solo per apertura nuova sede Settembre 2026', []),
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 →', []),
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'),
+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" },
+8 -67
View File
@@ -113,8 +113,6 @@ const gtmId = process.env.GTM_ID;
font: inherit; font-size: .9rem; color: #2b2320; cursor: pointer; }
.tm-field select:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-field select.is-set { border-color: #9c8b70; background-color: #f6f1e8; font-weight: 600; }
.tm-field select.is-estimated { border-style: dashed; border-color: #c4b79f; color: #6b5b48; }
.tm-note { margin: 12px 0 0; font: 400 12px/1.5 system-ui, sans-serif; color: #8a7c68; }
.tag-modal__box input[type=file] { display: block; width: 100%; font: inherit; font-size: .9rem; padding: 10px;
border: 1px dashed #cbbfad; border-radius: 10px; background: #fff; box-sizing: border-box; }
.tm-del { margin-top: 12px; padding: 8px 14px; border: 0; border-radius: 8px; background: #b4443a; color: #fff; cursor: pointer; font: 600 .85rem system-ui, sans-serif; }
@@ -153,42 +151,6 @@ const gtmId = process.env.GTM_ID;
color: { accent: 'Accento', 'accent-dark': 'Accento scuro', dark: 'Scuro', heading: 'Titolo', text: 'Testo', 'text-light': 'Testo chiaro', white: 'Bianco' },
};
const valLabel = (g, v) => (VALUE_LABELS[g] && VALUE_LABELS[g][v]) || v;
// Stima il preset più vicino all'aspetto REALE dell'elemento (getComputedStyle).
const px = (s) => parseFloat(s) || 0;
const rgb = (s) => { const m = String(s).match(/-?\d+(\.\d+)?/g); return m ? m.slice(0, 3).map(Number) : null; };
const SIZE_SCALE = { s: 0.85, m: 1, l: 1.25, xl: 1.5, xxl: 2 };
let colorProbe = null;
function nearestSize(el) {
const cs = getComputedStyle(el);
const ref = el.parentElement ? px(getComputedStyle(el.parentElement).fontSize) : px(cs.fontSize);
const ratio = ref ? px(cs.fontSize) / ref : 1;
let best = 'm', bd = Infinity;
for (const k in SIZE_SCALE) { const d = Math.abs(SIZE_SCALE[k] - ratio); if (d < bd) { bd = d; best = k; } }
return best;
}
function nearestColor(el) {
const a = rgb(getComputedStyle(el).color); if (!a) return null;
if (!colorProbe) { colorProbe = document.createElement('span'); colorProbe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden'; document.body.appendChild(colorProbe); }
let best = null, bd = Infinity;
STYLE_GROUPS.color.forEach((c) => {
colorProbe.className = 'cs-color-' + c;
const p = rgb(getComputedStyle(colorProbe).color); if (!p) return;
const d = (p[0]-a[0])**2 + (p[1]-a[1])**2 + (p[2]-a[2])**2;
if (d < bd) { bd = d; best = c; }
});
colorProbe.className = '';
return best;
}
function effective(group, el) {
const cs = getComputedStyle(el);
if (group === 'size') return nearestSize(el);
if (group === 'color') return nearestColor(el);
if (group === 'weight') return parseInt(cs.fontWeight, 10) >= 600 ? 'bold' : 'normal';
if (group === 'style') return cs.fontStyle.indexOf('italic') >= 0 ? 'italic' : 'normal';
if (group === 'align') { let x = cs.textAlign; if (x === 'start') x = 'left'; else if (x === 'end') x = 'right'; return ['left','center','right','justify'].indexOf(x) >= 0 ? x : 'left'; }
return null;
}
const modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid');
@@ -233,12 +195,7 @@ const gtmId = process.env.GTM_ID;
if (groups.length) {
const grid = document.createElement('div');
grid.className = 'tm-styles';
let anyEstimate = false;
groups.forEach((group) => {
const explicit = styles[group]; // valore salvato nel blocco
const est = explicit ? null : effective(group, el); // altrimenti stima dall'aspetto reale
const shown = explicit || est;
if (est) anyEstimate = true;
const field = document.createElement('label');
field.className = 'tm-field';
const cap = document.createElement('span');
@@ -247,32 +204,22 @@ const gtmId = process.env.GTM_ID;
const sel = document.createElement('select');
sel.dataset.group = group;
sel.className = 'tm-style';
if (est) sel.dataset.estimated = est; // marcata come stima: non fissata al salvataggio se invariata
const none = document.createElement('option');
none.value = ''; none.textContent = '— default (tema) —';
none.value = ''; none.textContent = '— default —';
sel.appendChild(none);
STYLE_GROUPS[group].forEach((v) => {
const o = document.createElement('option');
o.value = v; o.textContent = valLabel(group, v) + (est === v ? ' (attuale)' : '');
if (shown === v) o.selected = true;
o.value = v; o.textContent = valLabel(group, v);
if (styles[group] === v) o.selected = true;
sel.appendChild(o);
});
sel.classList.add(explicit ? 'is-set' : (est ? 'is-estimated' : ''));
sel.addEventListener('change', () => {
delete sel.dataset.estimated; // scelta manuale → diventa esplicita
sel.classList.remove('is-estimated');
sel.classList.toggle('is-set', !!sel.value);
});
// Evidenzia la combo se un valore è impostato.
if (styles[group]) sel.classList.add('is-set');
sel.addEventListener('change', () => sel.classList.toggle('is-set', !!sel.value));
field.appendChild(cap); field.appendChild(sel);
grid.appendChild(field);
});
tmBody.appendChild(grid);
if (anyEstimate) {
const note = document.createElement('p');
note.className = 'tm-note';
note.textContent = 'I valori "(attuale)" riflettono laspetto dal tema: cambiane uno per fissarlo sul blocco.';
tmBody.appendChild(note);
}
}
}
modal.hidden = false;
@@ -290,16 +237,10 @@ const gtmId = process.env.GTM_ID;
return;
}
const value = document.getElementById('tm-value').value;
const selects = document.querySelectorAll('.tm-style');
const styles = {};
selects.forEach((s) => {
const v = s.value;
if (!v) return; // "default (tema)" → non impostato
if (s.dataset.estimated && v === s.dataset.estimated) return; // stima non modificata → non fissare
styles[s.dataset.group] = v;
});
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
const body = { value };
if (selects.length) body.styles = styles; // inviato sempre (anche {}) così l'azzeramento persiste
if (Object.keys(styles).length) body.styles = styles;
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
+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.
+7 -1
View File
@@ -107,7 +107,10 @@ const wa = t('global.whatsapp');
});
</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; }
@@ -126,6 +129,9 @@ const wa = t('global.whatsapp');
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);
});