From 0b5d9f41064c9ba2521e2aca6cab2763207c7342 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 17 Aug 2026 12:27:31 +0200 Subject: [PATCH 1/9] 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=, solo per chi e' loggato. Co-Authored-By: Claude Opus 5 (1M context) --- src/data/content-seed.ts | 77 ++++++++++++ src/data/promo-edizioni.ts | 94 ++++++++++++++ src/pages/promo-eventi.astro | 233 +++++++++++++++++++++++------------ tests/promo-edizioni.test.ts | 107 ++++++++++++++++ 4 files changed, 433 insertions(+), 78 deletions(-) create mode 100644 src/data/promo-edizioni.ts create mode 100644 tests/promo-edizioni.test.ts diff --git a/src/data/content-seed.ts b/src/data/content-seed.ts index d55f900..c8396a9 100644 --- a/src/data/content-seed.ts +++ b/src/data/content-seed.ts @@ -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 WhatsApp 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.
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.
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 4 settembre.
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 4 settembre.
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 4 settembre.
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.

Una volta terminata l\'iscrizione, riceverai un\'email con le istruzioni per scaricare la nostra app Bsport. Dopo aver effettuato l\'accesso, entra nella sezione Club: qui troverai tutti i pacchetti disponibili. Seleziona il bundle che preferisci e completa l\'acquisto direttamente dall\'app effettuando il pagamento.

Se hai bisogno di aiuto scrivi su WhatsApp ({{whatsapp}}) 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 fino al 4 settembre 2026 e si utilizzano dal 1° settembre al 1° dicembre 2026. Dopo l\'acquisto scrivici su WhatsApp ({{whatsapp}}) 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 WhatsApp ({{whatsapp}}) 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 WhatsApp ({{whatsapp}}) per organizzare le sessioni.'), + T('bundle.faq.4.q', 'Il pagamento è sicuro?'), + H('bundle.faq.4.a', 'Sì. Il pagamento avviene tramite Stripe, 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])); diff --git a/src/data/promo-edizioni.ts b/src/data/promo-edizioni.ts new file mode 100644 index 0000000..8119db0 --- /dev/null +++ b/src/data/promo-edizioni.ts @@ -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 (`.*`, 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 `.card..*`. */ + id: number; + /** Quante voci ha l'elenco puntato (`feature.1` … `feature.N`). */ + features: number; + /** Card in evidenza: cornice diversa e badge `card..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; +} diff --git a/src/pages/promo-eventi.astro b/src/pages/promo-eventi.astro index 2c9de55..25449ee 100644 --- a/src/pages/promo-eventi.astro +++ b/src/pages/promo-eventi.astro @@ -2,99 +2,145 @@ 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, -// inserisce i dati e paga via Stripe dentro la piattaforma. Link centralizzato qui: -// quando avremo gli ID membership per singolo prodotto basterà mappare BSPORT_URL card per card. +// 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; -// I testi sono editabili dal pannello contenuti (tag `promo.*`). Qui resta solo la -// struttura: quante card, quante feature ciascuna e quale è "in evidenza". -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 = [0, 1, 2, 3, 4, 5, 6]; +// 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 anteprima = Boolean(utente && quandoParam && !Number.isNaN(Date.parse(quandoParam))); +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'); -// Scadenza della promo (tag editabile dal pannello, formato 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 e non con quello del render (la pagina è SSR ma può essere servita da cache). -const countdownDeadline = t('promo.countdown.deadline'); +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')) : ''; --- - +]*>/g, ' ').replace(/\s+/g, ' ').trim() : 'Le promozioni di apertura sono terminate: scrivici su WhatsApp per i percorsi attivi.'}>
-
- - - - - - -
- -
- - - - -
- -
- -
- -
-
+
+ {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); + } + }); +}); From cbcb533e57a07a24aa0bd2d34f9269ba853b9a2c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 17 Aug 2026 12:32:57 +0200 Subject: [PATCH 2/9] Promo: un'anteprima non applicata lo dice, invece di mostrare oggi in silenzio Senza login (o con una data illeggibile) il parametro veniva ignorato e la pagina rispondeva con l'edizione corrente: due date diverse davano lo stesso risultato, e si legge come una funzione rotta invece che come un accesso mancante. Ora una barra rossa dichiara il motivo e come rimediare. Co-Authored-By: Claude Opus 5 (1M context) --- src/pages/promo-eventi.astro | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pages/promo-eventi.astro b/src/pages/promo-eventi.astro index 25449ee..7f678d3 100644 --- a/src/pages/promo-eventi.astro +++ b/src/pages/promo-eventi.astro @@ -25,7 +25,12 @@ 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 anteprima = Boolean(utente && quandoParam && !Number.isNaN(Date.parse(quandoParam))); +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 @@ -53,6 +58,16 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : ''; {ed ? ` edizione «${ed.id}»` : ' nessuna edizione attiva'} )} + {anteprimaIgnorata === 'login' && ( +
+ Anteprima non applicata: questa è la pagina di oggi. Entra da /admin/login e ricarica. +
+ )} + {anteprimaIgnorata === 'data' && ( +
+ Anteprima non applicata: «{param}» non è una data leggibile. Usa il formato 2026-08-25. +
+ )} {ed ? ( @@ -305,6 +320,10 @@ const countdownDeadline = ed ? t(tg('countdown.deadline')) : ''; 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; } From a18a5052541d33b72d3b70b879e6eea50dc3c484 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 17 Aug 2026 15:38:03 +0200 Subject: [PATCH 3/9] Servizi: foto e "Per saperne di piu'" della stessa card portano allo stesso posto Erano due espressioni diverse per lo stesso link e sono divergute: su One to One e Performance Class la foto apriva la scheda del servizio mentre il testo mandava a /training, quindi la destinazione dipendeva da dove capitava il dito. Adesso l'indirizzo lo calcola una funzione sola, usata da entrambi. Verificato sulla pagina renderizzata: sei card, zero link discordanti. Co-Authored-By: Claude Opus 5 (1M context) --- src/pages/services/index.astro | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pages/services/index.astro b/src/pages/services/index.astro index 38d47bf..ff7b0cc 100644 --- a/src/pages/services/index.astro +++ b/src/pages/services/index.astro @@ -9,9 +9,11 @@ import { t } from '../../lib/content'; const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const gridServices = services.filter((p) => p.grid !== false); -// Questi servizi rimandano alla pagina Training invece che alla scheda di dettaglio -const TRAINING_LINK = new Set(['one-to-one', 'performance-class']); -const moreHref = (slug: string) => (TRAINING_LINK.has(slug) ? '/training' : `/services/${slug}`); +// Foto e "Per saperne di più" della stessa card portano allo stesso posto, e lo fanno per +// costruzione: erano due espressioni diverse e sono divergute — su One to One e Performance +// Class il testo mandava a /training mentre la foto apriva la scheda del servizio, così la +// destinazione dipendeva da dove capitava il dito. +const hrefOf = (slug: string) => `/services/${slug}`; export const prerender = false; --- @@ -25,7 +27,7 @@ export const prerender = false; {gridServices.map((p, i) => ( ))} From 01f49ab6d2b45f0eca756a34b70702615216f6cc Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 17 Aug 2026 15:58:47 +0200 Subject: [PATCH 4/9] Calendario delle class: popup a pieno schermo dalle pagine Performance e Holistic Le due griglie fornite dal cliente (1-18 settembre, dal 21 in poi) sono trascritte come dato e ridisegnate nello stile del sito: si leggono da telefono, si stampano in PDF e per correggere una cella non serve rifare uno screenshot. Quale griglia compare lo decide la data, come per le edizioni della promo: quella in vigore, oppure - se nessuna lo e' ancora - la prossima, dichiarando da quando entra. Serve perche' il link esiste da subito e ad agosto un "nessun calendario" non direbbe niente a chi vuole gli orari di settembre. Il popup e' un nativo: fondale, Esc e trappola del focus senza librerie. "Scarica in PDF" stampa la sola griglia, gia' impaginata in orizzontale dal CSS di stampa. Le discipline hanno una barra colorata a sinistra oltre al fondo, cosi' restano distinguibili anche quando il browser stampa senza sfondi. Quattordici test sul dato: confini al secondo in ora italiana, fasce orarie uniche e in ordine, nessuna lezione in un giorno di chiusura, e il buco vero fra il 19 e il 20 settembre resta dichiarato invece che nascosto. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/CalendarioModal.astro | 203 +++++++++++++++++++++++++++ src/data/calendari.ts | 125 +++++++++++++++++ src/data/content-seed.ts | 9 ++ src/pages/services/[slug].astro | 21 +++ tests/calendari.test.ts | 95 +++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 src/components/CalendarioModal.astro create mode 100644 src/data/calendari.ts create mode 100644 tests/calendari.test.ts diff --git a/src/components/CalendarioModal.astro b/src/components/CalendarioModal.astro new file mode 100644 index 0000000..870730d --- /dev/null +++ b/src/components/CalendarioModal.astro @@ -0,0 +1,203 @@ +--- +// Popup a pieno schermo con la griglia delle class in Sala Piccola. +// +// Il calendario è ricostruito dal dato (`data/calendari.ts`) e non è l'immagine ricevuta dal +// cliente: si legge da telefono, si stampa, e per correggere una cella non serve rifare uno +// screenshot. Quale griglia compare lo decide la data — quella in vigore, o la prossima +// dichiarando da quando entra. +// +// Si apre da qualunque elemento con `data-apri-calendario` nella stessa pagina. +import { calendarioDaMostrare, GIORNI, DISCIPLINE, type Giorno } from '../data/calendari'; +import { t } from '../lib/content'; + +const scelta = calendarioDaMostrare(new Date()); +const cal = scelta?.cal ?? null; +const dal = cal ? new Date(cal.from).toLocaleDateString('it-IT', { + day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome', +}) : ''; +// Discipline davvero presenti in questa griglia: una legenda che elenca anche ciò che non c'è +// fa cercare lezioni che non esistono. +const presenti = cal + ? DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d))) + : []; +--- +{cal && ( + +
+
+
+

{cal.sala}

+

{cal.titolo}

+ {scelta!.futuro && ( +

{t('calendario.futuro-label')} {dal}

+ )} +
+
+ + +
+
+ +
+ + + + + {GIORNI.map((g) => ( + + ))} + + + + {cal.righe.map((r) => ( + + + {GIORNI.map((g: Giorno) => { + const chiuso = cal.chiusi.includes(g); + const lezione = r.celle[g]; + return ( + + ); + })} + + ))} + +
{cal.sala} + {g} + {cal.noteGiorno?.[g] && {cal.noteGiorno[g]}} +
{r.ora} + {chiuso ? {t('calendario.chiuso-label')} : lezione ?? ''} +
+
+ +
+
    + {presenti.map((d) => ( +
  • {d}
  • + ))} +
+

{t('calendario.nota')}

+
+
+
+)} + + + + diff --git a/src/data/calendari.ts b/src/data/calendari.ts new file mode 100644 index 0000000..40fcbdc --- /dev/null +++ b/src/data/calendari.ts @@ -0,0 +1,125 @@ +// Calendari delle class in Sala Piccola. +// +// Trascritti dai due fogli forniti dal cliente il 10/08/2026 (immagini su Wasabi, cartella +// del progetto). Sono dato e non immagini di proposito: così la griglia si legge da telefono, +// si stampa in PDF e si corregge una cella senza rifare uno screenshot. +// +// Ogni calendario porta la sua finestra di validità: la pagina mostra quello in vigore, e +// quando nessuno lo è ancora mostra il prossimo dichiarando da quando entra. Il passaggio da +// un orario all'altro avviene da solo, senza deploy — stesso principio delle edizioni della +// pagina promo (`data/promo-edizioni.ts`). + +export const GIORNI = ['LUN', 'MAR', 'MER', 'GIO', 'VEN', 'SAB', 'DOM'] as const; +export type Giorno = (typeof GIORNI)[number]; + +/** Le discipline della sala: il colore di ognuna è definito nel componente del calendario. */ +export const DISCIPLINE = ['REFORMER', 'PILATES', 'YOGA', 'MOBILITY'] as const; +export type Disciplina = (typeof DISCIPLINE)[number]; + +export interface RigaCalendario { + ora: string; + /** Fascia messa in risalto nel foglio del cliente (le righe gialle). */ + clou?: boolean; + celle: Partial>; +} + +export interface Calendario { + id: string; + /** Come lo chiama il cliente: è anche l'etichetta mostrata in pagina. */ + titolo: string; + sala: string; + /** Validità, ISO con offset esplicito (il server gira in UTC). `to: null` = a tempo indeterminato. */ + from: string; + to: string | null; + /** Giorni in cui la sala è chiusa: colonna barrata, non vuota — vuoto e chiuso non sono lo stesso. */ + chiusi: Giorno[]; + /** Precisazione su una colonna, es. il sabato aperto una volta sola. */ + noteGiorno?: Partial>; + righe: RigaCalendario[]; +} + +export const calendari: Calendario[] = [ + { + id: 'settembre-1-18', + titolo: "Dall'1 al 18 settembre", + sala: 'Sala piccola', + from: '2026-09-01T00:00:00+02:00', + to: '2026-09-18T23:59:59+02:00', + chiusi: ['DOM'], + noteGiorno: { SAB: 'solo il 12 settembre' }, + righe: [ + { ora: '6.15', celle: {} }, + { ora: '7.00', clou: true, celle: { MAR: 'YOGA' } }, + { ora: '7.45', clou: true, celle: { MAR: 'PILATES', MER: 'REFORMER' } }, + { ora: '8.30', celle: { MER: 'REFORMER', VEN: 'MOBILITY' } }, + { ora: '9.15', celle: { VEN: 'REFORMER', SAB: 'MOBILITY' } }, + { ora: '10.00', celle: { VEN: 'REFORMER', SAB: 'PILATES' } }, + { ora: '10.45', celle: { SAB: 'REFORMER' } }, + { ora: '11.30', celle: { SAB: 'REFORMER' } }, + { ora: '12.15', clou: true, celle: { MAR: 'REFORMER', MER: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '13.00', clou: true, celle: { MAR: 'REFORMER', MER: 'PILATES', GIO: 'REFORMER' } }, + { ora: '13.45', celle: {} }, + { ora: '14.30', celle: { MAR: 'REFORMER' } }, + { ora: '15.15', celle: { MAR: 'MOBILITY' } }, + { ora: '16.00', celle: {} }, + { ora: '16.45', celle: { MAR: 'REFORMER' } }, + { ora: '17.30', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '18.15', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '19.00', clou: true, celle: { MAR: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '19.45', clou: true, celle: { MAR: 'REFORMER', GIO: 'PILATES' } }, + ], + }, + { + id: 'settembre-21-in-poi', + titolo: 'Dal 21 settembre in poi', + sala: 'Sala piccola', + from: '2026-09-21T00:00:00+02:00', + to: null, + chiusi: ['DOM'], + righe: [ + { ora: '6.15', celle: { MER: 'MOBILITY' } }, + { ora: '7.00', clou: true, celle: { MAR: 'YOGA', MER: 'REFORMER' } }, + { ora: '7.45', clou: true, celle: { MAR: 'PILATES', MER: 'REFORMER', VEN: 'PILATES' } }, + { ora: '8.30', celle: { MAR: 'REFORMER', MER: 'REFORMER', VEN: 'MOBILITY' } }, + { ora: '9.15', celle: { VEN: 'REFORMER', SAB: 'MOBILITY' } }, + { ora: '10.00', celle: { VEN: 'REFORMER', SAB: 'PILATES' } }, + { ora: '10.45', celle: { SAB: 'REFORMER' } }, + { ora: '11.30', celle: { GIO: 'YOGA', SAB: 'REFORMER' } }, + { ora: '12.15', clou: true, celle: { MAR: 'REFORMER', MER: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '13.00', clou: true, celle: { MAR: 'REFORMER', MER: 'PILATES', GIO: 'REFORMER' } }, + { ora: '13.45', celle: {} }, + { ora: '14.30', celle: { MAR: 'REFORMER' } }, + { ora: '15.15', celle: { MAR: 'MOBILITY' } }, + { ora: '16.00', celle: {} }, + { ora: '16.45', celle: { MAR: 'REFORMER' } }, + // ⚠️ Mercoledì 17.30 e 18.15: nel foglio ricevuto quelle due celle sono coperte dal + // riquadro "Opzioni Incolla" di Excel, quindi non sono leggibili. Qui restano vuote + // perché inventarle sarebbe peggio: vanno confermate dal cliente. + { ora: '17.30', clou: true, celle: { LUN: 'PILATES', MAR: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '18.15', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '19.00', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'REFORMER' } }, + { ora: '19.45', clou: true, celle: { LUN: 'REFORMER', MAR: 'REFORMER', GIO: 'PILATES' } }, + ], + }, +]; + +/** Servizi dalla cui pagina si apre il calendario della sala. */ +export const SERVIZI_CON_CALENDARIO = ['performance-class', 'holistic-class']; + +/** + * Il calendario da mostrare: quello in vigore adesso, oppure — se nessuno lo è ancora — il + * primo che entrerà in vigore, segnalato come futuro. `null` solo se non ce n'è nessuno da + * mostrare, e in quel caso la voce non compare in pagina. + * + * Serve il ripiego sul futuro perché il link esiste **prima** che il primo orario cominci: un + * "nessun calendario disponibile" ad agosto non direbbe niente a chi vuole sapere gli orari + * di settembre. + */ +export function calendarioDaMostrare(now: Date, list: Calendario[] = calendari): + { cal: Calendario; futuro: boolean } | null { + const t = now.getTime(); + const attivo = list.find((c) => t >= Date.parse(c.from) && (c.to === null || t <= Date.parse(c.to))); + if (attivo) return { cal: attivo, futuro: false }; + const prossimi = list.filter((c) => Date.parse(c.from) > t).sort((a, b) => Date.parse(a.from) - Date.parse(b.from)); + return prossimi.length ? { cal: prossimi[0], futuro: true } : null; +} diff --git a/src/data/content-seed.ts b/src/data/content-seed.ts index c8396a9..286899e 100644 --- a/src/data/content-seed.ts +++ b/src/data/content-seed.ts @@ -386,6 +386,15 @@ 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 WhatsApp o via email. Valutiamo ogni situazione in modo diretto e umano.'), + // --- Calendario delle class (popup dalle pagine servizio) --- + // La griglia è dato (`data/calendari.ts`); qui ci sono solo le etichette attorno. + T('calendario.link-label', 'Calendario', []), + T('calendario.pdf-label', 'Scarica in PDF', []), + T('calendario.chiudi-label', 'Chiudi', []), + T('calendario.futuro-label', 'In vigore dal', []), + T('calendario.chiuso-label', 'Chiuso', []), + T('calendario.nota', 'Gli orari possono subire variazioni. Le lezioni si prenotano dall\'app Bsport.'), + // --- 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. diff --git a/src/pages/services/[slug].astro b/src/pages/services/[slug].astro index fdfa72f..8b5ef1c 100644 --- a/src/pages/services/[slug].astro +++ b/src/pages/services/[slug].astro @@ -7,12 +7,18 @@ import { performanceClassPricing } from '../../data/pricing'; import { serviceIcons } from '../../data/service-icons'; import T from '../../components/content/T.astro'; import TImg from '../../components/content/TImg.astro'; +import CalendarioModal from '../../components/CalendarioModal.astro'; +import { calendarioDaMostrare, SERVIZI_CON_CALENDARIO } from '../../data/calendari'; import { t } from '../../lib/content'; export const prerender = false; const service = getService(Astro.params.slug!); if (!service) return new Response(null, { status: 404 }); const slug = service.slug; +// Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è +// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è +// peggio di nessun link. +const conCalendario = SERVIZI_CON_CALENDARIO.includes(slug) && calendarioDaMostrare(new Date()) !== null; const others = services.filter((p) => p.slug !== service.slug && p.grid !== false); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; @@ -59,6 +65,16 @@ const tones = ['tan', 'taupe', 'dark']; ))} + {conCalendario && ( + + )} @@ -95,6 +111,8 @@ const tones = ['tan', 'taupe', 'dark']; )} + + {conCalendario && } diff --git a/src/pages/promo-eventi/index.astro b/src/pages/promo-eventi/index.astro new file mode 100644 index 0000000..194213e --- /dev/null +++ b/src/pages/promo-eventi/index.astro @@ -0,0 +1,56 @@ +--- +import Base from '../../layouts/Base.astro'; +import PageHero from '../../components/PageHero.astro'; +import T from '../../components/content/T.astro'; +import { t } from '../../lib/content'; +import { edizioneAttiva } from '../../data/promo-edizioni'; +export const prerender = false; + +// Chi entra da «Promo ed eventi» sceglie: le promozioni in corso oppure gli eventi del +// centro (chiesto dal cliente il 17/08/2026). Erano la stessa pagina, e chi cercava +// l'inaugurazione finiva in mezzo ai prezzi dei pacchetti. +// +// Il bottone delle promo dice se ce n'è davvero una in corso: mandare su una pagina che +// risponde «nessuna promozione» senza averlo detto prima è un giro a vuoto. +const ed = edizioneAttiva(new Date()); +// La pagina cambia contenuto col passare del tempo (l'edizione promo scade da sola): una +// copia in cache la congelerebbe a com'era. +Astro.response.headers.set('Cache-Control', 'no-store'); +--- + + + +
+ +
+ + + diff --git a/src/pages/promo-eventi.astro b/src/pages/promo-eventi/promo.astro similarity index 98% rename from src/pages/promo-eventi.astro rename to src/pages/promo-eventi/promo.astro index 7f678d3..bf2ccc5 100644 --- a/src/pages/promo-eventi.astro +++ b/src/pages/promo-eventi/promo.astro @@ -1,10 +1,10 @@ --- -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'; +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: diff --git a/src/pages/services/[slug].astro b/src/pages/services/[slug].astro index 231c648..e57bf97 100644 --- a/src/pages/services/[slug].astro +++ b/src/pages/services/[slug].astro @@ -8,18 +8,20 @@ import { serviceIcons } from '../../data/service-icons'; import T from '../../components/content/T.astro'; import TImg from '../../components/content/TImg.astro'; import CalendarioModal from '../../components/CalendarioModal.astro'; -import { calendariDaMostrare, SERVIZI_CON_CALENDARIO } from '../../data/calendari'; +import { calendariDaMostrare, PAGINE_CON_CALENDARIO } from '../../data/calendari'; import { t } from '../../lib/content'; export const prerender = false; const service = getService(Astro.params.slug!); -if (!service) return new Response(null, { status: 404 }); +// `page: false` = servizio senza scheda (oggi la Performance Class, che non si eroga più): +// la voce esiste ancora nei dati per la card in griglia, ma la pagina non c'è. +if (!service || service.page === false) return new Response(null, { status: 404 }); const slug = service.slug; // Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è // davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è // peggio di nessun link. -const conCalendario = SERVIZI_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0; -const others = services.filter((p) => p.slug !== service.slug && p.grid !== false); +const conCalendario = PAGINE_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0; +const others = services.filter((p) => p.slug !== service.slug && p.grid !== false && p.page !== false); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const heroImg = imgOf(service.slug); @@ -44,6 +46,15 @@ const tones = ['tan', 'taupe', 'dark']; {heroImg && ( )} + {service.extra && ( +
+ + +
    + {service.extra.items.map((_, i) => )} +
+
+ )}
{service.faq.map((_, i) => (
@@ -119,6 +130,8 @@ const tones = ['tan', 'taupe', 'dark']; .prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; } .prog :global(.prog__desc) { white-space: pre-line; } .prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; } + .prog__extra { margin-bottom: 10px; } + .prog__extra h2 { letter-spacing: .1em; margin-bottom: .5em; } .prog__features li::marker { color: var(--c-accent); } .prog__img { width: 100%; height: auto; margin-bottom: 30px; } .prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; } diff --git a/src/pages/services/index.astro b/src/pages/services/index.astro index ff7b0cc..edbf294 100644 --- a/src/pages/services/index.astro +++ b/src/pages/services/index.astro @@ -5,6 +5,8 @@ import type { ImageMetadata } from 'astro'; import { services } from '../../data/services'; import T from '../../components/content/T.astro'; import TImg from '../../components/content/TImg.astro'; +import CalendarioModal from '../../components/CalendarioModal.astro'; +import { calendariDaMostrare, CARD_CON_CALENDARIO } from '../../data/calendari'; import { t } from '../../lib/content'; const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; @@ -13,7 +15,13 @@ const gridServices = services.filter((p) => p.grid !== false); // costruzione: erano due espressioni diverse e sono divergute — su One to One e Performance // Class il testo mandava a /training mentre la foto apriva la scheda del servizio, così la // destinazione dipendeva da dove capitava il dito. -const hrefOf = (slug: string) => `/services/${slug}`; +// Alcune card non portano alla propria scheda ma a una vista di /training (One to One e +// Class, deciso dal cliente il 17/08/2026): la destinazione la dichiara il servizio. +const hrefOf = (p: (typeof services)[number]) => p.href ?? `/services/${p.slug}`; +// Il calendario delle class si apre anche da qui, dalla card Class: chi guarda i servizi +// vuole sapere quando si allena prima ancora di aprire la scheda. Compare solo se c'è +// davvero una griglia da mostrare, come nelle pagine delle class. +const conCalendario = calendariDaMostrare(new Date()).length > 0; export const prerender = false; --- @@ -27,7 +35,7 @@ export const prerender = false; {gridServices.map((p, i) => (
))} + {conCalendario && } diff --git a/src/pages/training.astro b/src/pages/training/[...vista].astro similarity index 52% rename from src/pages/training.astro rename to src/pages/training/[...vista].astro index f9e8d0a..3a52fc8 100644 --- a/src/pages/training.astro +++ b/src/pages/training/[...vista].astro @@ -1,28 +1,37 @@ --- -import Base from '../layouts/Base.astro'; -import PageHero from '../components/PageHero.astro'; -import TrainingCards from '../components/home/TrainingCards.astro'; +import Base from '../../layouts/Base.astro'; +import PageHero from '../../components/PageHero.astro'; +import TrainingCards from '../../components/home/TrainingCards.astro'; import { Image } from 'astro:assets'; -import { trainings } from '../data/trainings'; -import T from '../components/content/T.astro'; -import { t } from '../lib/content'; -const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true }); +import type { ImageMetadata } from 'astro'; +import { VISTE_TRAINING, trainingDaMostrare } from '../../data/trainings'; +import T from '../../components/content/T.astro'; +import { t } from '../../lib/content'; +const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true }); const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default; export const prerender = false; + +// `/training` mostra tutto; `/training/one-to-one` e `/training/class` il solo sottoinsieme +// da cui si arriva cliccando la card corrispondente in /services. Un indirizzo inventato è +// 404 e non l'elenco intero: un filtro che non c'è deve vedersi, non passare per buono. +const vista = Astro.params.vista; +const daMostrare = trainingDaMostrare(vista); +if (!daMostrare) return new Response(null, { status: 404 }); +const filtrata = Boolean(vista); ---
- + {filtrata ?

{VISTE_TRAINING[vista!].titolo}

: }
- +
- {trainings.map((tr, i) => ( + {daMostrare.map((tr, i) => (
{imgOf(tr.image) && {t(`training.${tr.id}.title`)}}
@@ -32,6 +41,11 @@ export const prerender = false;
))} + {/* Da una vista filtrata si esce: senza questo link chi è entrato da una card di + /services non ha modo di vedere gli altri training se non dal menu. */} + {filtrata && ( +

{t('training.tutti.cta-label')}

+ )}
@@ -40,5 +54,6 @@ export const prerender = false; .tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); } .tblock--reverse img { order: 2; } .tblock h2 { letter-spacing: .1em; } + .tblock__tutti { text-align: center; margin-top: 20px; } @media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } } diff --git a/tests/modifiche-agosto.test.ts b/tests/modifiche-agosto.test.ts new file mode 100644 index 0000000..16f1a79 --- /dev/null +++ b/tests/modifiche-agosto.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { createDb } from '../src/lib/db'; +import { services, getService } from '../src/data/services'; +import { trainings, trainingDaMostrare, VISTE_TRAINING } from '../src/data/trainings'; +import { contentSeed } from '../src/data/content-seed'; +import { site } from '../src/data/site'; +import { PAGINE_CON_CALENDARIO, CARD_CON_CALENDARIO } from '../src/data/calendari'; +import { rinominaSmallGroup, calcola, applica, TESTI } from '../scripts/testi-17-agosto.mjs'; + +// Le modifiche chieste dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf). + +describe('Small Group è diventato Personalized Training Group', () => { + it('non resta traccia del vecchio nome nei dati né nel seed', () => { + const testi = [ + ...services.flatMap((s) => [s.title, s.subtitle, s.excerpt, s.longDescription, ...s.features, ...s.faq.flatMap((f) => [f.q, f.a])]), + ...trainings.flatMap((t) => [t.title, t.caption, t.description]), + ...site.footerTraining.map((v) => v.label), + ...contentSeed.map((e) => e.value), + ]; + expect(testi.filter((v) => /small group/i.test(v))).toEqual([]); + }); + + // Lo slug resta `small-group`: è l'indirizzo della scheda, e cambiarlo romperebbe i link + // già girati su WhatsApp senza che nessuno se ne accorga. + it('lo slug della scheda non cambia', () => { + expect(getService('small-group')?.title).toBe('Personalized Training Group'); + }); + + it('gli articoli restano corretti, e passarci due volte non cambia niente', () => { + const casi: [string, string][] = [ + ['Lo Small Group di InsanityLab è un allenamento', 'Il Personalized Training Group di InsanityLab è un allenamento'], + ['nuovi format come gli small group e una', 'nuovi format come i Personalized Training Group e una'], + ['One to One / Small Group / Rehab', 'One to One / Personalized Training Group / Rehab'], + ['Small Groups', 'Personalized Training Group'], + ['in formula small group (massimo 4 persone)', 'in formula Personalized Training Group (massimo 4 persone)'], + ]; + for (const [prima, dopo] of casi) { + expect(rinominaSmallGroup(prima)).toBe(dopo); + expect(rinominaSmallGroup(dopo)).toBe(dopo); + } + }); +}); + +describe('lo script porta i testi nuovi in un database già scritto', () => { + it('aggiorna le righe vecchie e al secondo giro non ha più niente da fare', () => { + const db = createDb(':memory:'); + const scrivi = db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); + scrivi.run('service.one-to-one.subtitle', 'text', 'One to One / Small Group / Rehab'); + scrivi.run('service.profilazione.subtitle', 'text', 'Profilazione e valutazione fisica'); + scrivi.run('footer.services.4.label', 'text', 'Profilazione'); + + const cambi = applica(db); + const dopo = (tag: string) => (db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value; + expect(dopo('service.one-to-one.subtitle')).toBe('One to One / Personalized Training Group / Rehab'); + expect(dopo('service.profilazione.subtitle')).toBe('Base e avanzato'); + expect(dopo('footer.services.4.label')).toBe('Check-up'); + expect(cambi.length).toBeGreaterThanOrEqual(3); + + expect(calcola(db)).toEqual([]); + }); + + // Un `--dry-run` che scrivesse renderebbe inutile guardare prima di toccare la produzione. + it('con --dry-run non tocca niente', () => { + const db = createDb(':memory:'); + db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)') + .run('promo.card.2.title', 'text', 'Small Group'); + expect(applica(db, { dryRun: true }).length).toBe(1); + expect((db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('promo.card.2.title') as { value: string }).value) + .toBe('Small Group'); + }); + + it('i testi riscritti a mano corrispondono a quelli del seed', () => { + const seed = new Map(contentSeed.map((e) => [e.tag, e.value])); + for (const [tag, valore] of Object.entries(TESTI)) expect(seed.get(tag)).toBe(valore); + }); +}); + +describe('viste di /training', () => { + it('senza vista si vedono tutti i training', () => { + expect(trainingDaMostrare()?.map((t) => t.id)).toEqual(trainings.map((t) => t.id)); + }); + + it('One to One porta a One to One, Personalized Training Group e Rehab', () => { + expect(trainingDaMostrare('one-to-one')?.map((t) => t.id)).toEqual(['one-to-one', 'small-groups', 'rehab']); + }); + + it('Class porta a Holistic e Reformer', () => { + expect(trainingDaMostrare('class')?.map((t) => t.id)).toEqual(['holistic-class', 'reformer-class']); + }); + + // Un filtro sbagliato che mostra tutto farebbe passare per buono un indirizzo rotto. + it('una vista inventata è 404, non l\'elenco intero', () => { + expect(trainingDaMostrare('inesistente')).toBeNull(); + }); + + it('ogni vista nomina solo training che esistono', () => { + for (const v of Object.values(VISTE_TRAINING)) { + for (const id of v.blocchi) expect(trainings.some((t) => t.id === id)).toBe(true); + } + }); +}); + +describe('instradamento delle card di /services', () => { + const href = (slug: string) => { const s = getService(slug)!; return s.href ?? `/services/${s.slug}`; }; + + it('One to One e Class portano alle viste di training', () => { + expect(href('one-to-one')).toBe('/training/one-to-one'); + expect(href('performance-class')).toBe('/training/class'); + }); + + it('Check-up porta alla propria scheda', () => { + expect(href('profilazione')).toBe('/services/profilazione'); + }); + + // La Performance Class non si eroga più: la card resta, la scheda che la spiegava no. + it('la Performance Class non ha più una scheda ma resta in griglia', () => { + expect(getService('performance-class')?.page).toBe(false); + expect(getService('performance-class')?.grid).not.toBe(false); + }); + + it('nessun link del sito manda a una scheda che non esiste', () => { + const schede = new Set(services.filter((s) => s.page !== false).map((s) => `/services/${s.slug}`)); + const interni = [...site.footerServices, ...site.footerTraining, ...site.navigation] + .map((v) => v.href) + .filter((h) => h.startsWith('/services/')); + for (const h of interni) expect(schede.has(h)).toBe(true); + }); +}); + +describe('Check-up', () => { + const checkup = getService('profilazione')!; + + it('il trafiletto della card racconta Base e Avanzato', () => { + expect(checkup.title).toBe('Check-up'); + expect(checkup.subtitle).toBe('Base e avanzato'); + expect(checkup.excerpt).toContain('Check-up Base'); + expect(checkup.excerpt).toContain('Check-up Avanzato'); + }); + + it('la scheda porta la sezione avanzata con i suoi cinque punti', () => { + expect(checkup.extra?.title).toBe('Check-up Avanzato'); + expect(checkup.extra?.body).toContain('Wellness Tower'); + expect(checkup.extra?.items).toHaveLength(5); + }); + + it('i testi della sezione avanzata sono modificabili dal pannello', () => { + const tags = new Set(contentSeed.map((e) => e.tag)); + expect(tags.has('service.profilazione.extra.title')).toBe(true); + expect(tags.has('service.profilazione.extra.body')).toBe(true); + for (let i = 1; i <= 5; i++) expect(tags.has(`service.profilazione.extra.item.${i}`)).toBe(true); + }); +}); + +describe('calendario delle class', () => { + // Il calendario si apre da due punti diversi: le schede delle class che si fanno, e la + // card «Class» della griglia servizi, che ha slug `performance-class`. + it('le schede sono Holistic e Reformer, la Performance Class è fuori', () => { + expect(PAGINE_CON_CALENDARIO).toEqual(['holistic-class', 'reformer-class']); + }); + + it('in griglia il bottone sta sulla card Class', () => { + expect(CARD_CON_CALENDARIO).toEqual(['performance-class']); + }); + + it('ogni slug nominato esiste davvero', () => { + for (const slug of [...PAGINE_CON_CALENDARIO, ...CARD_CON_CALENDARIO]) expect(getService(slug)).toBeDefined(); + }); +}); + +describe('promo ed eventi', () => { + it('i testi della pagina di scelta e degli eventi sono nel seed', () => { + const tags = new Set(contentSeed.map((e) => e.tag)); + for (const tag of ['promo-eventi.hero.title', 'promo-eventi.promo.label', 'promo-eventi.eventi.label', + 'eventi.hero.title', 'eventi.inaugurazione.title', 'eventi.inaugurazione.body']) { + expect(tags.has(tag)).toBe(true); + } + }); + + it('l\'inaugurazione dice la data e che il programma manca ancora', () => { + const testo = contentSeed.find((e) => e.tag === 'eventi.inaugurazione.body')!.value; + expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.title')!.value).toContain('19 settembre 2026'); + expect(testo).toContain('programma completo'); + }); + + // La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti. + it('il menu punta alla pagina di scelta', () => { + expect(site.navigation.some((v) => v.href === '/promo-eventi')).toBe(true); + }); +});