Files
InsanityLab-web/apps/sito/src/pages/promo-eventi/promo.astro
T
Adriano Dal Pastro 33091be802 Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts:
#	apps/sito/compose.yaml
2026-09-04 10:09:29 +00:00

383 lines
21 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" />
{c.priceOld && <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; }
/* CARD DELLE EDIZIONI A CINQUE (promo settembre). Cinque non si divide in una griglia a due
o a tre colonne senza lasciare un buco in fondo: la griglia è a sei colonne e ogni card ne
occupa due, così le ultime due si spostano al centro invece di restare allineate a sinistra
con un vuoto accanto. */
.lp .cards-5 { grid-template-columns: repeat(6, 1fr); }
.lp .cards-5 .card { grid-column: span 2; display: flex; flex-direction: column; }
.lp .cards-5 .card .buy { margin-top: auto; }
.lp .cards-5 .card:nth-child(4) { grid-column: 2 / span 2; }
.lp .cards-5 .card:nth-child(5) { grid-column: 4 / span 2; }
.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; }
/* Sotto i 1000px tre colonne diventano troppo strette per un prezzo e un elenco: le cinque
card passano a due per riga (griglia a quattro, span due), con la quinta al centro.
Nessun `min-width` a chiudere la fascia in basso: a viewport frazionari (uno zoom del
browser dà larghezze come 700,5px) fra 700 e 701 non varrebbe nessuna delle due regole e
le card tornerebbero a sei colonne. Ci pensa il blocco ≤700px, che viene dopo e a pari
specificità vince. */
@media (max-width: 1000px) {
.lp .cards-5 { grid-template-columns: repeat(4, 1fr); }
.lp .cards-5 .card:nth-child(4) { grid-column: span 2; }
.lp .cards-5 .card:nth-child(5) { grid-column: 2 / span 2; }
}
@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; }
/* A una colonna gli span e le collocazioni delle ultime due card lascerebbero righe vuote. */
.lp .cards-5 .card,
.lp .cards-5 .card:nth-child(4),
.lp .cards-5 .card:nth-child(5) { grid-column: auto; }
.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>