Compare commits

..

5 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
11 changed files with 105 additions and 30 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>
+2 -1
View File
@@ -18,7 +18,8 @@ export const site = {
{ label: "Blog & Edugo", href: "/blog" },
{ label: "Promo ed eventi", href: "/promo-eventi" },
{ label: "Contatti", href: "/contact" },
{ label: "Promo 2 (test)", href: "/promo-eventi2" },
// 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" },
+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.
+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);
});