Promo: le edizioni si scambiano da sole alla data stabilita

La pagina non mostra piu' una promozione sola scritta nel codice: sceglie
l'edizione la cui finestra contiene l'istante della richiesta. Il 20 agosto alle
00:00 la promo agosto lascia il posto agli Opening Bundles senza deploy, e dopo
il 4 settembre la pagina dichiara che non c'e' nessuna promozione invece di
lasciare online prezzi scaduti.

Le edizioni sono dato (data/promo-edizioni.ts): finestra, quante card, quali
campi ha ciascuna. I testi restano tag modificabili dal pannello, col prefisso
dell'edizione. I bundle aggiungono domanda, descrizione e riquadro del periodo
di utilizzo, presi dal volantino del cliente.

Le date portano l'offset esplicito: il server gira in UTC e senza offset lo
scambio avverrebbe due ore prima di quanto scritto sul sito. La pagina esce con
Cache-Control: no-store, perche' una copia in cache la congelerebbe all'edizione
vecchia.

Dieci test: le finestre non si sovrappongono e non lasciano buchi, il countdown
scade quando finisce l'edizione, e ogni tag richiesto esiste nel seed - un tag
mancante non romperebbe la pagina, lascerebbe una card senza prezzo.

Anteprima con ?anteprima=<data>, solo per chi e' loggato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:27:31 +02:00
parent cd73906c08
commit 0b5d9f4106
4 changed files with 433 additions and 78 deletions
+77
View File
@@ -386,6 +386,83 @@ export const contentSeed: SeedEntry[] = [
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
// --- Pagina promo, stato "nessuna promozione in corso" ---
// Fra un'edizione e la successiva la pagina resta raggiungibile (è nel menu e nei link già
// girati ai clienti): mostra questo invece dei prezzi di una promozione finita.
T('promo.chiusa.title', 'Nessuna promozione in corso'),
H('promo.chiusa.desc', 'Le offerte di apertura sono terminate.<br>Scrivici su WhatsApp: ti raccontiamo i percorsi attivi e troviamo quello giusto per te.'),
T('promo.chiusa.cta', 'Scrivici su WhatsApp', []),
// --- Opening Bundles (edizione `bundle-apertura`, dal 20 agosto al 4 settembre 2026) ---
// Testi dal volantino fornito dal cliente il 13/08. La struttura delle card (domanda,
// descrizione, riquadro date) è dichiarata in `data/promo-edizioni.ts`.
T('bundle.hero.badge', 'New Opening · Offerta esclusiva'),
T('bundle.hero.title1', 'Opening Bundles.'),
T('bundle.hero.accent', 'Non è uno sconto.'),
T('bundle.hero.title2', 'È un accesso privilegiato.'),
H('bundle.hero.desc', 'Tre percorsi costruiti su misura, disponibili solo per la nuova apertura.<br>Acquistabili fino al 4 settembre, utilizzabili dal 1° settembre al 1° dicembre.'),
T('bundle.hero.cta', 'Scopri i tre percorsi', []),
T('bundle.buy-label', 'Acquista', []),
T('bundle.card.1.eyebrow', 'Personal Training', []),
T('bundle.card.1.title', 'Intensive'),
T('bundle.card.1.question', 'Ti alleni ma non vedi risultati?'),
T('bundle.card.1.price', '450 €', []),
T('bundle.card.1.price-old', '770 €', []),
T('bundle.card.1.feature.1', 'Profilazione Avanzata'),
T('bundle.card.1.feature.2', '8 sessioni PT 1:1'),
T('bundle.card.1.feature.3', '4 Community Class'),
T('bundle.card.1.desc', 'Un check-up avanzato per conoscere davvero il tuo corpo — poi 2 sessioni di Personal Training e una Community Class a settimana. Un mese per capire di cosa sei capace.'),
T('bundle.card.1.note-label', 'Periodo di utilizzo', []),
H('bundle.card.1.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
T('bundle.card.2.eyebrow', 'Pilates & Holistic', []),
T('bundle.card.2.title', 'Restore'),
T('bundle.card.2.question', 'Hai dolori, rigidità o ti senti bloccato?'),
T('bundle.card.2.price', '170 €', []),
T('bundle.card.2.price-old', '220 €', []),
T('bundle.card.2.feature.1', '4 Pilates Reformer'),
T('bundle.card.2.feature.2', '4 Holistic Class'),
T('bundle.card.2.desc', 'Se hai dolori, rigidità, o semplicemente vuoi prenderti cura di te con dolcezza. Reformer e Holistic Class lavorano insieme per rimettere il corpo in equilibrio — mobilità, core, respiro. Senza forzare, senza improvvisare.'),
T('bundle.card.2.note-label', 'Periodo di utilizzo', []),
H('bundle.card.2.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
T('bundle.card.3.eyebrow', 'Training & Wellness', []),
T('bundle.card.3.title', 'Recharge'),
T('bundle.card.3.question', 'Sei stanco e svuotato anche dopo una notte di sonno?'),
T('bundle.card.3.price', '250 €', []),
T('bundle.card.3.price-old', '310 €', []),
T('bundle.card.3.feature.1', 'Analisi HRV'),
T('bundle.card.3.feature.2', '4 Pilates Reformer'),
T('bundle.card.3.feature.3', '3 Training Group'),
T('bundle.card.3.desc', 'Stanco, svuotato, fuori ritmo. Attraverso l\'analisi HRV misuriamo il tuo livello di stress e il tuo stato energetico reale — poi Reformer e Training Group per farti ripartire davvero.'),
T('bundle.card.3.note-label', 'Periodo di utilizzo', []),
H('bundle.card.3.note', 'Acquistabile fino al <strong>4 settembre</strong>.<br>Utilizzabile dal 1° settembre al 1° dicembre.'),
// La deadline è la stessa fine dell'edizione in `promo-edizioni.ts`: un test lo verifica,
// perché un countdown che scade a un'ora diversa dallo scambio fa contraddire il sito.
T('bundle.countdown.label', 'I bundle scadono tra', []),
T('bundle.countdown.deadline', '2026-09-04T23:59:59+02:00', []),
T('bundle.countdown.unit.days', 'giorni', []),
T('bundle.countdown.unit.hours', 'ore', []),
T('bundle.countdown.unit.minutes', 'minuti', []),
T('bundle.countdown.unit.seconds', 'secondi', []),
T('bundle.validity-note', 'Acquistabili fino al 4 settembre 2026 · Utilizzabili dal 1° settembre al 1° dicembre 2026'),
T('bundle.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('bundle.faq.0.q', 'Come faccio ad acquistare un bundle?'),
H('bundle.faq.0.a', 'Clicca sul bundle che desideri e compila tutti i campi richiesti per completare la registrazione.<br><br>Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app <span class="hl">Bsport</span>. Dopo aver effettuato l\'accesso, entra nella sezione <span class="hl">Club</span>: qui troverai tutti i pacchetti disponibili. Seleziona il bundle che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.<br><br>Se hai bisogno di aiuto scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> e sarai presto ricontattato!'),
T('bundle.faq.1.q', 'Entro quando posso acquistarlo, e entro quando devo usarlo?'),
H('bundle.faq.1.a', 'I bundle si acquistano <span class="hl">fino al 4 settembre 2026</span> e si utilizzano <span class="hl">dal 1° settembre al 1° dicembre 2026</span>. Dopo l\'acquisto scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> per fissare il primo incontro: verrai ricontattato subito tramite messaggio o chiamata.'),
T('bundle.faq.2.q', 'Posso scambiare gli incontri fra un bundle e l\'altro?'),
H('bundle.faq.2.a', 'No: ogni bundle è un percorso costruito su un obiettivo preciso, e le attività che contiene lavorano insieme. Se ti interessano servizi di bundle diversi puoi acquistarne più di uno, oppure scrivici su <span class="hl">WhatsApp ({{whatsapp}})</span> e ti aiutiamo a scegliere.'),
T('bundle.faq.3.q', 'Posso regalare un bundle a qualcun altro?'),
H('bundle.faq.3.a', 'Sì. Puoi acquistare il bundle a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('bundle.faq.4.q', 'Il pagamento è sicuro?'),
H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, una delle piattaforme di pagamento online più sicure al mondo. I tuoi dati bancari non vengono mai condivisi con insanity.lab — tutto è gestito in modo cifrato da Stripe.'),
];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+94
View File
@@ -0,0 +1,94 @@
// Edizioni della pagina "Promo ed eventi".
//
// Una promozione ha una fine, e la pagina che la mostra deve saperla: qui sta scritto quale
// set di contenuti è in vetrina e in quale finestra di tempo. La pagina non decide niente,
// legge l'edizione attiva nell'istante della richiesta — così il passaggio da una promozione
// alla successiva avviene da solo, alla mezzanotte giusta, senza deploy e senza che qualcuno
// debba ricordarsene.
//
// I testi restano nei tag (`<prefix>.*`, modificabili senza deploy). Qui c'è solo la
// struttura: quante card, quali campi ha ciascuna, quali FAQ. Aggiungere un'edizione =
// una voce in questo elenco + i suoi tag nel seed; i test in `tests/promo-edizioni.test.ts`
// verificano che non manchi niente e che le finestre non si sovrappongano né lascino buchi.
export interface CardSpec {
/** Numero della card: forma i tag `<prefix>.card.<id>.*`. */
id: number;
/** Quante voci ha l'elenco puntato (`feature.1` … `feature.N`). */
features: number;
/** Card in evidenza: cornice diversa e badge `card.<id>.badge`. */
featured?: boolean;
/** Riga sotto il prezzo, es. "5 incontri". */
unit?: boolean;
/** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */
question?: boolean;
/** Paragrafo descrittivo sotto l'elenco. */
desc?: boolean;
/** Riquadro in fondo col periodo di acquisto e di utilizzo. */
note?: boolean;
}
export interface Edizione {
id: string;
/** Prefisso dei tag di contenuto di questa edizione. */
prefix: string;
/** Inizio e fine validità, ISO **con offset esplicito**: il server gira in UTC e senza
* offset lo scambio avverrebbe due ore prima di quanto scritto sul sito. */
from: string;
to: string;
cards: CardSpec[];
/** Numeri delle FAQ, nell'ordine di comparsa. */
faq: number[];
/** Sezioni "launch" e "why5" fra l'hero e le card. */
launch?: boolean;
}
export const edizioni: Edizione[] = [
{
// Promo di lancio: 4 pacchetti da 5 incontri, online dal 1° agosto.
id: 'agosto-2026',
prefix: 'promo',
from: '2026-08-01T00:00:00+02:00',
to: '2026-08-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true },
{ id: 2, features: 4, unit: true, featured: true },
{ id: 3, features: 3, unit: true },
{ id: 4, features: 3, unit: true },
],
faq: [0, 1, 2, 3, 4, 5, 6],
},
{
// Opening Bundles: subentra alla promo agosto il giorno dopo la sua scadenza, si
// acquistano fino al 4 settembre e si usano dal 1° settembre al 1° dicembre.
id: 'bundle-apertura',
prefix: 'bundle',
from: '2026-08-20T00:00:00+02:00',
to: '2026-09-04T23:59:59+02:00',
cards: [
{ id: 1, features: 3, question: true, desc: true, note: true },
{ id: 2, features: 2, question: true, desc: true, note: true },
// Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli
// dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato.
{ id: 3, features: 3, question: true, desc: true, note: true },
],
faq: [0, 1, 2, 3, 4],
},
];
/** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */
export const tagEdizione = (e: Edizione, suffisso: string): string => `${e.prefix}.${suffisso}`;
/**
* L'edizione valida in un istante, o `null` se non ce n'è nessuna (e allora la pagina mostra
* lo stato "nessuna promozione in corso" invece di prezzi scaduti).
*
* Il confronto è fra istanti assoluti: `Date.parse` interpreta l'offset scritto nel dato, e
* `now` arriva dall'orologio del server. Nessuna delle due parti dipende dal fuso della
* macchina, che in produzione è UTC e in sviluppo no.
*/
export function edizioneAttiva(now: Date, list: Edizione[] = edizioni): Edizione | null {
const t = now.getTime();
return list.find((e) => t >= Date.parse(e.from) && t <= Date.parse(e.to)) ?? null;
}