3 Commits

Author SHA1 Message Date
Adriano 7a30bc08b9 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:10 +02:00
Adriano bcc965655d 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:08:58 +02:00
Adriano 8d593dce90 editor inline: combo mostrano il valore reale stimato dall'aspetto
Se un blocco non ha uno stile esplicito, le combo pre-selezionano il preset più
vicino all'aspetto effettivo (getComputedStyle): size dal rapporto col genitore,
color per match RGB, weight/style/align diretti. Marcato "(attuale)" con bordo
tratteggiato. Al salvataggio le stime non modificate non vengono fissate, così il
tema resta intatto; solo le scelte manuali vengono scritte. Gli styles ora sono
inviati sempre per permettere l'azzeramento a "default (tema)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:39:46 +02:00
5 changed files with 72 additions and 204 deletions
+4 -7
View File
@@ -33,7 +33,6 @@ export const contentSeed: SeedEntry[] = [
T('global.nav.4.label', site.navigation[3].label, []), T('global.nav.4.label', site.navigation[3].label, []),
T('global.nav.5.label', site.navigation[4].label, []), T('global.nav.5.label', site.navigation[4].label, []),
T('global.nav.6.label', site.navigation[5].label, []), T('global.nav.6.label', site.navigation[5].label, []),
T('global.nav.7.label', site.navigation[6].label, []),
// --- Header --- // --- Header ---
T('header.logo.alt', 'InsanityLab', []), T('header.logo.alt', 'InsanityLab', []),
@@ -302,24 +301,22 @@ export const contentSeed: SeedEntry[] = [
T('notfound.cta', 'Torna alla home', []), T('notfound.cta', 'Torna alla home', []),
// --- Promo ed eventi (promo-eventi.astro) --- // --- Promo ed eventi (promo-eventi.astro) ---
// Titolo del banner PageHero (usato dalla variante promo-eventi2)
T('promo.pagehero.title', 'Promo ed eventi'),
// Hero // 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.title1', 'Allenati con metodo.'),
T('promo.hero.accent', 'Scegli come'), T('promo.hero.accent', 'Scegli come'),
T('promo.hero.title2', 'vuoi farlo.'), 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.'), 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 // 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.title', 'Il nostro metodo nasce per durare.'),
T('promo.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."), 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."), 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 // 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.'), 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) // Label bottone acquisto (condivisa dalle 4 card)
T('promo.buy-label', 'Acquista', []), T('promo.buy-label', 'ACQUISTA →', []),
// Card 1 — One to One // Card 1 — One to One
T('promo.card.1.eyebrow', 'Personal training', []), T('promo.card.1.eyebrow', 'Personal training', []),
T('promo.card.1.title', 'One to One'), T('promo.card.1.title', 'One to One'),
-1
View File
@@ -18,7 +18,6 @@ export const site = {
{ label: "Blog & Edugo", href: "/blog" }, { label: "Blog & Edugo", href: "/blog" },
{ label: "Promo ed eventi", href: "/promo-eventi" }, { label: "Promo ed eventi", href: "/promo-eventi" },
{ label: "Contatti", href: "/contact" }, { label: "Contatti", href: "/contact" },
{ label: "Promo 2 (test)", href: "/promo-eventi2" },
], ],
footerTraining: [ footerTraining: [
{ label: "One to one", href: "/training#one-to-one" }, { label: "One to one", href: "/training#one-to-one" },
+67 -8
View File
@@ -113,6 +113,8 @@ const gtmId = process.env.GTM_ID;
font: inherit; font-size: .9rem; color: #2b2320; cursor: pointer; } 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: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-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; .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; } 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; } .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; }
@@ -151,6 +153,42 @@ 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' }, 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; 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 modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag'); const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid'); const tmGuid = document.getElementById('tm-guid');
@@ -195,7 +233,12 @@ const gtmId = process.env.GTM_ID;
if (groups.length) { if (groups.length) {
const grid = document.createElement('div'); const grid = document.createElement('div');
grid.className = 'tm-styles'; grid.className = 'tm-styles';
let anyEstimate = false;
groups.forEach((group) => { 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'); const field = document.createElement('label');
field.className = 'tm-field'; field.className = 'tm-field';
const cap = document.createElement('span'); const cap = document.createElement('span');
@@ -204,22 +247,32 @@ const gtmId = process.env.GTM_ID;
const sel = document.createElement('select'); const sel = document.createElement('select');
sel.dataset.group = group; sel.dataset.group = group;
sel.className = 'tm-style'; sel.className = 'tm-style';
if (est) sel.dataset.estimated = est; // marcata come stima: non fissata al salvataggio se invariata
const none = document.createElement('option'); const none = document.createElement('option');
none.value = ''; none.textContent = '— default —'; none.value = ''; none.textContent = '— default (tema) —';
sel.appendChild(none); sel.appendChild(none);
STYLE_GROUPS[group].forEach((v) => { STYLE_GROUPS[group].forEach((v) => {
const o = document.createElement('option'); const o = document.createElement('option');
o.value = v; o.textContent = valLabel(group, v); o.value = v; o.textContent = valLabel(group, v) + (est === v ? ' (attuale)' : '');
if (styles[group] === v) o.selected = true; if (shown === v) o.selected = true;
sel.appendChild(o); sel.appendChild(o);
}); });
// Evidenzia la combo se un valore è impostato. sel.classList.add(explicit ? 'is-set' : (est ? 'is-estimated' : ''));
if (styles[group]) sel.classList.add('is-set'); sel.addEventListener('change', () => {
sel.addEventListener('change', () => sel.classList.toggle('is-set', !!sel.value)); delete sel.dataset.estimated; // scelta manuale → diventa esplicita
sel.classList.remove('is-estimated');
sel.classList.toggle('is-set', !!sel.value);
});
field.appendChild(cap); field.appendChild(sel); field.appendChild(cap); field.appendChild(sel);
grid.appendChild(field); grid.appendChild(field);
}); });
tmBody.appendChild(grid); 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; modal.hidden = false;
@@ -237,10 +290,16 @@ const gtmId = process.env.GTM_ID;
return; return;
} }
const value = document.getElementById('tm-value').value; const value = document.getElementById('tm-value').value;
const selects = document.querySelectorAll('.tm-style');
const styles = {}; const styles = {};
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; }); 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;
});
const body = { value }; const body = { value };
if (Object.keys(styles).length) body.styles = styles; if (selects.length) body.styles = styles; // inviato sempre (anche {}) così l'azzeramento persiste
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), { const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}); });
+1 -7
View File
@@ -107,10 +107,7 @@ const wa = t('global.whatsapp');
}); });
</script> </script>
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo <style>
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 { background: #ECE7DC; }
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; } .lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
@@ -129,9 +126,6 @@ const wa = t('global.whatsapp');
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer; border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
text-decoration: none; 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 */ /* LAUNCH */
.lp .launch { background: #F2ECE1; padding: 72px 64px; } .lp .launch { background: #F2ECE1; padding: 72px 64px; }
-181
View File
@@ -1,181 +0,0 @@
---
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>