From 01f49ab6d2b45f0eca756a34b70702615216f6cc Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 17 Aug 2026 15:58:47 +0200 Subject: [PATCH] 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 && }