-
-
+ {anteprima && (
+
+ Anteprima al {quando.toLocaleString('it-IT', { timeZone: 'Europe/Rome' })} —
+ {ed ? ` edizione «${ed.id}»` : ' nessuna edizione attiva'}
-
- {cards.map((c) => (
-
- {c.featured &&
}
-
-
-
-
-
+ )}
+
+ {ed ? (
+
+
+
+ {ed.launch && (
+
+
+
+
+
+ )}
+
+
+
+
+
-
-
- {Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
-
- ))}
-
-
- ))}
-
-
-
+
+ {ed.cards.map((c) => (
+
+ {c.featured &&
}
+
+
+ {c.question &&
}
+
+
+
+
+ {c.unit &&
}
+
+ {Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
+
+ ))}
+
+ {c.desc &&
}
+ {c.note && (
+
+
+
+
+ )}
+
+
+ ))}
+
+
+
-
-
- {faqs.map((n) => {
- const a = resolveContent(`promo.faq.${n}.a`);
- return (
-
- );
- })}
-
+
+
+ {ed.faq.map((n) => {
+ const a = resolveContent(tg(`faq.${n}.a`));
+ return (
+
+ );
+ })}
+
+
+ ) : (
+
+ )}
@@ -229,6 +275,37 @@ const countdownDeadline = t('promo.countdown.deadline');
.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;
+ }
+
/* 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; }
diff --git a/tests/promo-edizioni.test.ts b/tests/promo-edizioni.test.ts
new file mode 100644
index 0000000..82a6d14
--- /dev/null
+++ b/tests/promo-edizioni.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from 'vitest';
+import { edizioni, edizioneAttiva, tagEdizione, type Edizione } from '../src/data/promo-edizioni';
+import { seedByTag } from '../src/data/content-seed';
+
+const d = (iso: string) => new Date(iso);
+
+describe('edizioneAttiva', () => {
+ it('sceglie l\'edizione la cui finestra contiene l\'istante', () => {
+ expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
+ expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
+ });
+
+ it('fuori da ogni finestra non c\'è edizione attiva', () => {
+ expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
+ expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))).toBeNull();
+ });
+
+ // Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
+ // italiana. Il server gira in UTC, quindi un confronto fatto sull'orologio locale della
+ // macchina anticiperebbe (o ritarderebbe) lo scambio di due ore.
+ it('scambia le edizioni al secondo giusto, in ora italiana', () => {
+ expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026');
+ expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura');
+ expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
+ expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))).toBeNull();
+ });
+
+ it('l\'ordine dell\'elenco non cambia il risultato', () => {
+ const rovescio = [...edizioni].reverse();
+ expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026');
+ expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
+ });
+});
+
+describe('coerenza delle edizioni configurate', () => {
+ const ordinate = [...edizioni].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
+
+ it('ogni finestra è valida e ha un verso', () => {
+ for (const e of edizioni) {
+ expect(Number.isNaN(Date.parse(e.from)), `${e.id}: from illeggibile`).toBe(false);
+ expect(Number.isNaN(Date.parse(e.to)), `${e.id}: to illeggibile`).toBe(false);
+ expect(Date.parse(e.to), `${e.id}: finisce prima di cominciare`).toBeGreaterThan(Date.parse(e.from));
+ }
+ });
+
+ // Due promozioni insieme sul sito è il difetto peggiore: prezzi diversi per la stessa cosa,
+ // e nessuno sa quale vale. Nessun buco, invece, evita il giorno in cui la pagina non offre
+ // niente senza che nessuno se ne accorga: se un buco è voluto va scritto qui, non subìto.
+ it('le finestre non si sovrappongono e non lasciano buchi', () => {
+ for (let i = 1; i < ordinate.length; i++) {
+ const prima = ordinate[i - 1];
+ const dopo = ordinate[i];
+ const stacco = Date.parse(dopo.from) - Date.parse(prima.to);
+ expect(stacco, `${prima.id} e ${dopo.id} si sovrappongono`).toBeGreaterThan(0);
+ expect(stacco, `fra ${prima.id} e ${dopo.id} c'è un buco`).toBeLessThanOrEqual(1000);
+ }
+ });
+
+ // Il countdown è la promessa fatta al visitatore. Se dice una data e lo scambio ne usa
+ // un'altra, il sito si contraddice da solo: o cambia mentre il contatore corre ancora, o
+ // resta lì a zero. Sono lo stesso numero e devono restare lo stesso numero.
+ it('il countdown scade quando finisce l\'edizione', () => {
+ for (const e of edizioni) {
+ const deadline = seedByTag.get(tagEdizione(e, 'countdown.deadline'))?.value;
+ expect(deadline, `${e.id}: manca il tag della deadline`).toBeTruthy();
+ expect(Date.parse(deadline!), `${e.id}: countdown e fine edizione non coincidono`)
+ .toBe(Date.parse(e.to));
+ }
+ });
+});
+
+describe('i testi di ogni edizione esistono', () => {
+ // Un tag che manca non rompe la pagina: `resolve` restituisce stringa vuota e logga un warn
+ // che nessuno legge. In vetrina diventa una card senza prezzo, o un titolo sparito. Meglio
+ // scoprirlo qui che dal cliente.
+ const richiesti = (e: Edizione): string[] => {
+ const base = ['hero.badge', 'hero.title1', 'hero.accent', 'hero.title2', 'hero.desc', 'hero.cta',
+ 'buy-label', 'validity-note', 'faq.title',
+ 'countdown.label', 'countdown.deadline',
+ 'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds'];
+ if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
+ for (const c of e.cards) {
+ base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`, `card.${c.id}.price-old`);
+ if (c.featured) base.push(`card.${c.id}.badge`);
+ if (c.unit) base.push(`card.${c.id}.unit`);
+ if (c.question) base.push(`card.${c.id}.question`);
+ if (c.desc) base.push(`card.${c.id}.desc`);
+ if (c.note) base.push(`card.${c.id}.note-label`, `card.${c.id}.note`);
+ for (let i = 1; i <= c.features; i++) base.push(`card.${c.id}.feature.${i}`);
+ }
+ for (const n of e.faq) base.push(`faq.${n}.q`, `faq.${n}.a`);
+ return base.map((s) => tagEdizione(e, s));
+ };
+
+ for (const e of edizioni) {
+ it(`${e.id}: tutti i tag sono nel seed`, () => {
+ const mancanti = richiesti(e).filter((tag) => !seedByTag.has(tag));
+ expect(mancanti, `tag mancanti: ${mancanti.join(', ')}`).toEqual([]);
+ });
+ }
+
+ it('lo stato "nessuna promozione" ha i suoi testi', () => {
+ for (const tag of ['promo.chiusa.title', 'promo.chiusa.desc', 'promo.chiusa.cta']) {
+ expect(seedByTag.has(tag), `manca ${tag}`).toBe(true);
+ }
+ });
+});