Files
InsanityLab-web/src/pages/promo-eventi/promo.astro
T
Adriano 3741ce34ac Modifiche chieste dal cliente il 17 agosto
Il documento MODIFICHE_PER_SITO.pdf porta cinque cose.

1. "Small Group" si chiama "Personalized Training Group". Il nome vive anche
   nel database dei contenuti, dove `resolveContent` legge prima della copia
   nel codice: `scripts/testi-17-agosto.mjs` aggiorna le 19 righe rimaste,
   una volta sola, correggendo anche gli articoli ("Lo Small Group" -> "Il
   Personalized Training Group"). Lo slug della scheda resta `small-group`:
   cambiarlo romperebbe i link gia' girati su WhatsApp.

2. Dalla pagina Servizi le card instradano dove serve. "One to One" apre la
   vista Training con One to One, Personalized Training Group e Rehab;
   "Class" quella con Holistic e Reformer. Le viste sono `/training/<vista>`;
   una vista inventata e' 404, non l'elenco intero, altrimenti un indirizzo
   rotto continuerebbe a sembrare buono.

3. La Performance Class non si eroga piu': la scheda che la spiegava
   risponde 404, la card "Class" resta in griglia. Con la scheda e' caduta la
   tabella prezzi, che era solo sua.

4. Il Check-up racconta Base e Avanzato: trafiletto nuovo nella card e
   sezione "Check-up Avanzato" nella scheda, con la Wellness Tower e i cinque
   punti. Il calendario delle class si apre ora anche dalla card Class in
   griglia e dalla scheda Reformer, oltre che da Holistic.

5. "Promo ed eventi" diventa una pagina di scelta: le promozioni restano su
   /promo-eventi/promo, l'inaugurazione del 19 settembre sta su
   /promo-eventi/eventi. Il testo dice che il programma della giornata manca
   ancora, ed e' editabile dal pannello.

Corretti due link del footer che portavano a schede inesistenti
(`/services/personal-training` non e' mai stato uno slug).

181 test verdi (157 + 24 nuovi), build ok, `astro check` invariato.
2026-08-18 17:06:05 +02:00

357 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import Base from '../../layouts/Base.astro';
import T from '../../components/content/T.astro';
import { t, resolveContent } from '../../lib/content';
import { edizioneAttiva, tagEdizione } from '../../data/promo-edizioni';
import { getSessionUser, SESSION_COOKIE } from '../../lib/auth';
import { getDb } from '../../lib/db';
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account e sceglie lì
// dentro il prodotto, poi paga via Stripe. Il link è uno solo per tutte le card, ed è voluto:
// il catalogo lo presenta bsport dopo l'accesso.
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
export const prerender = false;
// Quale promozione è in vetrina lo decide il calendario, non un deploy: l'edizione attiva è
// quella la cui finestra contiene questo istante (vedi `data/promo-edizioni.ts`). Alla
// scadenza dell'una subentra l'altra da sola, e quando non ce n'è nessuna la pagina lo dice
// invece di lasciare online i prezzi di un'offerta finita.
//
// Anteprima: `?anteprima=2026-08-20` mostra l'edizione di quella data. Solo per chi è loggato
// — un visitatore non deve poter vedere prezzi non ancora validi, o non più.
const param = Astro.url.searchParams.get('anteprima');
const token = Astro.cookies.get(SESSION_COOKIE)?.value;
const utente = token ? getSessionUser(getDb(), token) : null;
// Una data secca (`2026-08-20`) sarebbe letta come UTC: la si porta a mezzogiorno italiano,
// lontano dai confini di mezzanotte dove sta la differenza fra un'edizione e l'altra.
const quandoParam = param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? `${param}T12:00:00+02:00` : param;
const dataLeggibile = Boolean(quandoParam && !Number.isNaN(Date.parse(quandoParam)));
const anteprima = Boolean(utente && dataLeggibile);
// Un'anteprima chiesta e non applicata va **detta**: altrimenti la pagina risponde con
// l'edizione di oggi e due date diverse danno lo stesso risultato — che si legge come «la
// funzione non funziona» invece che «non sei entrato».
const anteprimaIgnorata = !param || anteprima ? null : (!utente ? 'login' : 'data');
const quando = anteprima ? new Date(quandoParam!) : new Date();
const ed = edizioneAttiva(quando);
// La pagina cambia contenuto col passare del tempo: una copia in cache la congelerebbe alla
// vecchia edizione, e lo scambio avverrebbe quando pare al proxy invece che a mezzanotte.
Astro.response.headers.set('Cache-Control', 'no-store');
// Tag dell'edizione attiva: `tg('card.1.price')` → `promo.card.1.price` o `bundle.card.1.price`.
const tg = (suffisso: string) => (ed ? tagEdizione(ed, suffisso) : suffisso);
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
const wa = t('global.whatsapp');
const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`;
// Scadenza dell'edizione in corso (tag editabile dal pannello, ISO con offset). Viene solo
// passata al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto
// col tempo del visitatore.
const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
---
<Base title="Promo ed eventi — InsanityLab" description={ed ? t(tg('hero.desc')).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : 'Le promozioni di apertura sono terminate: scrivici su WhatsApp per i percorsi attivi.'}>
<div class="lp">
<div class="page">
{anteprima && (
<div class="anteprima">
Anteprima al {quando.toLocaleString('it-IT', { timeZone: 'Europe/Rome' })} —
{ed ? ` edizione «${ed.id}»` : ' nessuna edizione attiva'}
</div>
)}
{anteprimaIgnorata === 'login' && (
<div class="anteprima ignorata">
Anteprima non applicata: questa è la pagina di oggi. Entra da <a href="/admin/login">/admin/login</a> e ricarica.
</div>
)}
{anteprimaIgnorata === 'data' && (
<div class="anteprima ignorata">
Anteprima non applicata: «{param}» non è una data leggibile. Usa il formato 2026-08-25.
</div>
)}
{ed ? (
<Fragment>
<section class="hero">
<T tag={tg('hero.badge')} as="div" class="badge" />
<T tag={tg('hero.title1')} as="h1" />
<T tag={tg('hero.accent')} as="p" class="accent" />
<T tag={tg('hero.title2')} as="h1" />
<T tag={tg('hero.desc')} as="p" class="desc" />
<a class="btn-sand" href="#proposte"><T tag={tg('hero.cta')} as="span" /></a>
</section>
{ed.launch && (
<Fragment>
<section class="launch">
<T tag={tg('launch.badge')} as="div" class="badge" />
<T tag={tg('launch.title')} as="h1" />
<T tag={tg('launch.accent')} as="p" class="accent" />
<T tag={tg('launch.desc')} as="p" class="desc" />
</section>
<section class="why5">
<T tag={tg('why5.text')} as="p" />
</section>
</Fragment>
)}
<section class="cards-wrap" id="proposte">
<div class="countdown" data-deadline={countdownDeadline} hidden>
<T tag={tg('countdown.label')} as="div" class="cd-label" />
<div class="cd-boxes">
<div class="cd-box"><span class="cd-num" data-cd="days"></span><T tag={tg('countdown.unit.days')} as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="hours"></span><T tag={tg('countdown.unit.hours')} as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="minutes"></span><T tag={tg('countdown.unit.minutes')} as="span" class="cd-unit" /></div>
<div class="cd-box"><span class="cd-num" data-cd="seconds"></span><T tag={tg('countdown.unit.seconds')} as="span" class="cd-unit" /></div>
</div>
</div>
<div class:list={['cards', `cards-${ed.cards.length}`]}>
{ed.cards.map((c) => (
<div class:list={['card', c.featured && 'featured']}>
{c.featured && <T tag={tg(`card.${c.id}.badge`)} as="div" class="featured-badge" />}
<T tag={tg(`card.${c.id}.eyebrow`)} as="div" class="eyebrow" />
<T tag={tg(`card.${c.id}.title`)} as="div" class="title" />
{c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />}
<div class="price-row">
<T tag={tg(`card.${c.id}.price`)} as="div" class="price" />
<T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" />
</div>
{c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />}
<ul>
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
<T tag={tg(`card.${c.id}.feature.${m}`)} as="li" />
))}
</ul>
{c.desc && <T tag={tg(`card.${c.id}.desc`)} as="p" class="card-desc" />}
{c.note && (
<div class="note-box">
<T tag={tg(`card.${c.id}.note-label`)} as="div" class="note-label" />
<T tag={tg(`card.${c.id}.note`)} as="div" class="note-text" />
</div>
)}
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag={tg('buy-label')} as="span" /></a>
</div>
))}
</div>
<T tag={tg('validity-note')} as="p" class="validity-note" />
</section>
<section class="faq-wrap">
<T tag={tg('faq.title')} as="h1" />
{ed.faq.map((n) => {
const a = resolveContent(tg(`faq.${n}.a`));
return (
<div class="faq-block">
<div class="faq-q"><T tag={tg(`faq.${n}.q`)} as="p" /><div class="faq-toggle">+</div></div>
<p class:list={['faq-a', a.classes]} data-tag={tg(`faq.${n}.a`)} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
</div>
);
})}
</section>
</Fragment>
) : (
<section class="hero chiusa">
<T tag="promo.chiusa.title" as="h1" />
<T tag="promo.chiusa.desc" as="p" class="desc" />
<a class="btn-sand" href={waLink} target="_blank" rel="noopener"><T tag="promo.chiusa.cta" as="span" /></a>
</section>
)}
</div>
</div>
</Base>
<script>
// Countdown alla scadenza della promo. Parte nascosto (attributo `hidden` nel markup) e viene
// mostrato solo se la deadline è una data valida ancora futura: così, a promo finita, il blocco
// semplicemente non compare — nessun messaggio, nessun salto di layout.
const cd = document.querySelector<HTMLElement>('.lp .countdown');
if (cd) {
const end = Date.parse(cd.dataset.deadline || '');
const nums = new Map<string, HTMLElement>();
cd.querySelectorAll<HTMLElement>('.cd-num').forEach((n) => nums.set(n.dataset.cd!, n));
const pad = (n: number) => String(n).padStart(2, '0');
// Dichiarato prima di `tick` perché la prima chiamata è sincrona: se la promo è già scaduta
// il timer va fermato subito, e leggerlo dopo il `setInterval` darebbe un errore di TDZ.
let timer = 0;
const tick = () => {
const left = end - Date.now();
if (Number.isNaN(end) || left <= 0) { cd.hidden = true; clearInterval(timer); return; }
const s = Math.floor(left / 1000);
nums.get('days')!.textContent = String(Math.floor(s / 86400));
nums.get('hours')!.textContent = pad(Math.floor(s / 3600) % 24);
nums.get('minutes')!.textContent = pad(Math.floor(s / 60) % 60);
nums.get('seconds')!.textContent = pad(s % 60);
cd.hidden = false;
};
tick();
timer = setInterval(tick, 1000);
}
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
document.querySelectorAll('.lp .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): 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; }
/* HERO */
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
.lp .badge {
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
color: #C4A882; font-weight: 500; text-transform: uppercase;
}
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
.lp .btn-sand {
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
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; }
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
/* PERCHE 5 */
.lp .why5 { padding: 48px 64px 8px; }
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
/* COUNTDOWN */
/* `hidden` va forzato: i display flex qui sotto avrebbero comunque la meglio sul default UA. */
.lp .countdown[hidden] { display: none !important; }
.lp .countdown {
display: flex; flex-direction: column; align-items: center; gap: 14px;
background: #2D2D2D; border-radius: 14px; padding: 26px 24px; margin: 0 0 28px;
}
.lp .countdown .cd-label {
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; color: #C4A882;
}
.lp .countdown .cd-boxes { display: flex; gap: 10px; }
.lp .countdown .cd-box {
display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 74px;
background: rgba(255,255,255,0.06); border: 0.5px solid rgba(196,168,130,0.35);
border-radius: 10px; padding: 12px 10px;
}
.lp .countdown .cd-num { font-size: 30px; font-weight: 700; line-height: 1; color: #FFFFFF; font-variant-numeric: tabular-nums; }
.lp .countdown .cd-unit { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #B8B6AE; }
/* CARDS */
.lp .cards-wrap { padding: 32px 64px 72px; }
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
.lp .card.featured { border: 2px solid #2D2D2D; }
.lp .featured-badge {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
}
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
.lp .card .buy {
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
}
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
/* CARD DELLE EDIZIONI A TRE (Opening Bundles): domanda in testa, descrizione e riquadro
col periodo di utilizzo. Le card restano in colonna, quindi il bottone finisce ad altezze
diverse se i testi hanno lunghezze diverse: `flex` + `margin-top:auto` lo tiene in fondo. */
.lp .cards-3 { grid-template-columns: repeat(3, 1fr); }
.lp .cards-3 .card { display: flex; flex-direction: column; }
.lp .cards-3 .card .buy { margin-top: auto; }
.lp .card .question {
margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D;
}
.lp .card .card-desc {
margin: 0 0 18px; font-size: 13px; line-height: 1.6; color: #6E685C; font-style: italic;
}
.lp .card .note-box { background: #F2ECE1; border-radius: 8px; padding: 10px 12px; margin: 0 0 18px; }
.lp .card .note-label {
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700;
color: #8A6E3F; margin-bottom: 3px;
}
.lp .card .note-text { font-size: 12px; line-height: 1.5; color: #5C564A; }
/* Pagina senza nessuna promozione attiva: fra un'edizione e la successiva. */
.lp .hero.chiusa { padding-bottom: 72px; }
.lp .hero.chiusa h1 { font-size: 34px; }
.lp .hero.chiusa p.desc { margin-bottom: 28px; }
/* Barra di anteprima: visibile solo a chi è loggato e ha chiesto una data (?anteprima=…).
Deve essere impossibile scambiarla per la pagina vera. */
.lp .anteprima {
background: #8A6E3F; color: #FFFFFF; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
padding: 10px 16px; text-align: center;
}
/* Anteprima chiesta e non applicata: colore diverso, perché il contenuto sotto è quello di
oggi e non quello della data richiesta. */
.lp .anteprima.ignorata { background: #8B2E2E; }
.lp .anteprima.ignorata a { color: #FFFFFF; }
/* FAQ */
.lp .faq-wrap { padding: 72px 64px; }
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
.lp .faq-toggle {
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
transition: background .2s ease, color .2s ease, border-color .2s ease;
}
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
.lp .faq-block.is-open .faq-a { display: block; }
.lp .faq-a .hl { color: #8A6E3F; }
@media (max-width: 700px) {
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
.lp .cards { grid-template-columns: 1fr; }
.lp .countdown .cd-boxes { gap: 6px; }
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
.lp .countdown .cd-num { font-size: 22px; }
.lp .countdown .cd-unit { font-size: 9.5px; }
.lp .hero h1 { font-size: 32px; }
.lp .launch h1 { font-size: 28px; }
}
</style>