Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21d9755e37 | |||
| ee54e9a1f4 | |||
| 62e0120ea2 | |||
| 2f94fc0373 | |||
| e6c493b756 | |||
| 1522d97f54 | |||
| cc21cb7627 | |||
| 27fdf1fc30 | |||
| ae5eef037b | |||
| 931c1e43ad | |||
| a7b05d703a | |||
| 4cee8cdd7a | |||
| 1742c1d666 | |||
| 0b938d7d09 |
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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 l’aspetto 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),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 un’esperienza 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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user