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 <dialog> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||||
|
<dialog class="cal-dlg" aria-label={`${cal.sala} — ${cal.titolo}`}>
|
||||||
|
<div class="cal-dlg__box">
|
||||||
|
<header class="cal-dlg__top">
|
||||||
|
<div>
|
||||||
|
<p class="cal-dlg__sala">{cal.sala}</p>
|
||||||
|
<h2 class="cal-dlg__titolo">{cal.titolo}</h2>
|
||||||
|
{scelta!.futuro && (
|
||||||
|
<p class="cal-dlg__futuro">{t('calendario.futuro-label')} {dal}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="cal-dlg__azioni">
|
||||||
|
<button type="button" class="cal-dlg__btn" data-stampa-calendario>{t('calendario.pdf-label')}</button>
|
||||||
|
<button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cal-dlg__scroll">
|
||||||
|
<table class="cal-tab">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="cal-tab__ora-h">{cal.sala}</th>
|
||||||
|
{GIORNI.map((g) => (
|
||||||
|
<th scope="col" class:list={['cal-tab__gg', cal.chiusi.includes(g) && 'is-chiuso']}>
|
||||||
|
{g}
|
||||||
|
{cal.noteGiorno?.[g] && <span class="cal-tab__nota">{cal.noteGiorno[g]}</span>}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cal.righe.map((r) => (
|
||||||
|
<tr class:list={[r.clou && 'is-clou']}>
|
||||||
|
<th scope="row" class="cal-tab__ora">{r.ora}</th>
|
||||||
|
{GIORNI.map((g: Giorno) => {
|
||||||
|
const chiuso = cal.chiusi.includes(g);
|
||||||
|
const lezione = r.celle[g];
|
||||||
|
return (
|
||||||
|
<td class:list={['cal-tab__cella', chiuso && 'is-chiuso', lezione && `is-${lezione.toLowerCase()}`]}>
|
||||||
|
{chiuso ? <span class="cal-tab__chiuso-txt">{t('calendario.chiuso-label')}</span> : lezione ?? ''}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="cal-dlg__giu">
|
||||||
|
<ul class="cal-legenda">
|
||||||
|
{presenti.map((d) => (
|
||||||
|
<li class={`is-${d.toLowerCase()}`}>{d}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p class="cal-dlg__nota">{t('calendario.nota')}</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Apertura/chiusura del popup. Nessuna libreria: <dialog> nativo dà da solo il fondale, il
|
||||||
|
// focus trap e la chiusura con Esc.
|
||||||
|
const dlg = document.querySelector<HTMLDialogElement>('.cal-dlg');
|
||||||
|
if (dlg) {
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-apri-calendario]').forEach((el) => {
|
||||||
|
el.addEventListener('click', (e) => { e.preventDefault(); dlg.showModal(); });
|
||||||
|
});
|
||||||
|
dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close());
|
||||||
|
// Click sul fondale: il target è il dialog stesso solo quando si preme fuori dal riquadro.
|
||||||
|
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
|
||||||
|
// "Scarica in PDF" = stampa con la destinazione "Salva come PDF": la pagina di stampa è
|
||||||
|
// già impaginata per la sola griglia (vedi @media print), quindi non serve generare nulla
|
||||||
|
// lato server né caricare una libreria.
|
||||||
|
dlg.querySelector('[data-stampa-calendario]')?.addEventListener('click', () => window.print());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
.cal-dlg {
|
||||||
|
width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0;
|
||||||
|
border: 0; background: transparent; color: var(--c-text);
|
||||||
|
}
|
||||||
|
.cal-dlg::backdrop { background: rgba(43,43,43,.75); }
|
||||||
|
.cal-dlg__box {
|
||||||
|
display: flex; flex-direction: column; height: 100%; background: var(--c-bg);
|
||||||
|
padding: 28px clamp(16px, 4vw, 48px) 24px;
|
||||||
|
}
|
||||||
|
.cal-dlg__top { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
|
||||||
|
.cal-dlg__sala {
|
||||||
|
margin: 0; font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
|
||||||
|
letter-spacing: .25em; text-transform: uppercase; color: var(--c-accent-dark);
|
||||||
|
}
|
||||||
|
.cal-dlg__titolo { margin: 6px 0 0; font-size: 1.5rem; }
|
||||||
|
.cal-dlg__futuro {
|
||||||
|
margin: 6px 0 0; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .12em;
|
||||||
|
text-transform: uppercase; color: var(--c-text-light);
|
||||||
|
}
|
||||||
|
.cal-dlg__azioni { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||||
|
.cal-dlg__btn {
|
||||||
|
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .2em;
|
||||||
|
text-transform: uppercase; border: 0; cursor: pointer; padding: 13px 22px;
|
||||||
|
background: var(--c-accent); color: #fff; transition: background .2s;
|
||||||
|
}
|
||||||
|
.cal-dlg__btn:hover { background: var(--c-accent-dark); }
|
||||||
|
.cal-dlg__chiudi {
|
||||||
|
width: 42px; height: 42px; border: 1px solid #ddd; background: #fff; cursor: pointer;
|
||||||
|
font-size: 1.4rem; line-height: 1; color: var(--c-heading);
|
||||||
|
}
|
||||||
|
.cal-dlg__chiudi:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
|
||||||
|
|
||||||
|
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
|
||||||
|
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
|
||||||
|
.cal-dlg__scroll { flex: 1; overflow: auto; margin: 22px 0 0; }
|
||||||
|
.cal-tab { border-collapse: collapse; width: 100%; min-width: 720px; }
|
||||||
|
.cal-tab th, .cal-tab td { border: 1px solid #e4e0d9; padding: 9px 10px; text-align: left; }
|
||||||
|
.cal-tab thead th {
|
||||||
|
position: sticky; top: 0; z-index: 2; background: var(--c-bg-alt);
|
||||||
|
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
|
||||||
|
text-transform: uppercase; color: var(--c-heading);
|
||||||
|
}
|
||||||
|
.cal-tab__nota {
|
||||||
|
display: block; font-size: .62rem; letter-spacing: .06em; text-transform: none;
|
||||||
|
color: var(--c-text-light); font-weight: 400;
|
||||||
|
}
|
||||||
|
.cal-tab__ora-h { color: var(--c-accent-dark) !important; }
|
||||||
|
.cal-tab__ora {
|
||||||
|
position: sticky; left: 0; z-index: 1; background: var(--c-bg);
|
||||||
|
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; color: var(--c-heading);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cal-tab tr.is-clou .cal-tab__ora { background: #f7f2e6; }
|
||||||
|
.cal-tab__cella { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; color: var(--c-heading); }
|
||||||
|
/* Ogni disciplina ha un fondo tenue e una barra a sinistra: la barra si vede anche quando il
|
||||||
|
browser stampa senza sfondi. */
|
||||||
|
.cal-tab__cella.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
|
||||||
|
.cal-tab__cella.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
|
||||||
|
.cal-tab__cella.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
|
||||||
|
.cal-tab__cella.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
|
||||||
|
.cal-tab__cella.is-chiuso { background: repeating-linear-gradient(135deg, #f5f3f0, #f5f3f0 6px, #ece8e2 6px, #ece8e2 12px); }
|
||||||
|
.cal-tab__chiuso-txt { font-size: .62rem; letter-spacing: .12em; color: var(--c-text-light); }
|
||||||
|
.cal-tab thead th.is-chiuso { color: var(--c-text-light); }
|
||||||
|
|
||||||
|
.cal-dlg__giu { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 24px; padding-top: 16px; }
|
||||||
|
.cal-legenda { display: flex; flex-wrap: wrap; gap: 8px 16px; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.cal-legenda li {
|
||||||
|
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
|
||||||
|
color: var(--c-heading);
|
||||||
|
}
|
||||||
|
.cal-legenda li.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
|
||||||
|
.cal-legenda li.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
|
||||||
|
.cal-legenda li.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
|
||||||
|
.cal-legenda li.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
|
||||||
|
.cal-dlg__nota { margin: 0; font-size: .8rem; color: var(--c-text-light); }
|
||||||
|
|
||||||
|
/* Stampa (ossia "Salva come PDF"): resta solo la griglia, in orizzontale e a piena pagina.
|
||||||
|
`visibility` invece di `display` perché il dialog è annidato nel corpo della pagina e
|
||||||
|
nascondere gli antenati nasconderebbe anche lui. */
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 10mm; }
|
||||||
|
body { visibility: hidden; }
|
||||||
|
.cal-dlg, .cal-dlg * { visibility: visible; }
|
||||||
|
.cal-dlg { position: absolute; inset: 0; height: auto; }
|
||||||
|
.cal-dlg::backdrop { background: transparent; }
|
||||||
|
.cal-dlg__box { padding: 0; height: auto; }
|
||||||
|
.cal-dlg__azioni { display: none; }
|
||||||
|
.cal-dlg__scroll { overflow: visible; }
|
||||||
|
.cal-tab { min-width: 0; }
|
||||||
|
.cal-tab thead th, .cal-tab__ora { position: static; }
|
||||||
|
.cal-tab th, .cal-tab td { padding: 5px 6px; }
|
||||||
|
.cal-tab__cella { font-size: .62rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.cal-dlg__box { padding: 20px 14px 18px; }
|
||||||
|
.cal-dlg__titolo { font-size: 1.2rem; }
|
||||||
|
.cal-dlg__btn { padding: 11px 16px; letter-spacing: .12em; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<Record<Giorno, Disciplina>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<Giorno, string>>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -386,6 +386,15 @@ export const contentSeed: SeedEntry[] = [
|
|||||||
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
|
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.'),
|
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.'),
|
||||||
|
|
||||||
|
// --- 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" ---
|
// --- Pagina promo, stato "nessuna promozione in corso" ---
|
||||||
// Fra un'edizione e la successiva la pagina resta raggiungibile (è nel menu e nei link già
|
// 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.
|
// girati ai clienti): mostra questo invece dei prezzi di una promozione finita.
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import { performanceClassPricing } from '../../data/pricing';
|
|||||||
import { serviceIcons } from '../../data/service-icons';
|
import { serviceIcons } from '../../data/service-icons';
|
||||||
import T from '../../components/content/T.astro';
|
import T from '../../components/content/T.astro';
|
||||||
import TImg from '../../components/content/TImg.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';
|
import { t } from '../../lib/content';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const service = getService(Astro.params.slug!);
|
const service = getService(Astro.params.slug!);
|
||||||
if (!service) return new Response(null, { status: 404 });
|
if (!service) return new Response(null, { status: 404 });
|
||||||
const slug = service.slug;
|
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 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 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 imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||||
@@ -59,6 +65,16 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
<T tag={`service.${o.slug}.title`} />
|
<T tag={`service.${o.slug}.title`} />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
{conCalendario && (
|
||||||
|
<button type="button" class="prog__other prog__other--calendario" data-apri-calendario>
|
||||||
|
<span class="prog__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true">
|
||||||
|
<path d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4M8 14h3M8 17h3M14 14h2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{t('calendario.link-label')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -95,6 +111,8 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{conCalendario && <CalendarioModal />}
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -109,6 +127,9 @@ const tones = ['tan', 'taupe', 'dark'];
|
|||||||
.prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; }
|
.prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; }
|
||||||
.prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; }
|
.prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
.prog__other:hover { color: var(--c-accent-dark); }
|
.prog__other:hover { color: var(--c-accent-dark); }
|
||||||
|
/* La voce del calendario apre un popup, quindi è un bottone e non un link: qui gli si
|
||||||
|
toglie l'aspetto da bottone perché nella colonna sia indistinguibile dalle altre voci. */
|
||||||
|
.prog__other--calendario { background: none; border: 0; padding: 0; cursor: pointer; width: 100%; text-align: left; }
|
||||||
.prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; }
|
.prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; }
|
||||||
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
|
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
|
||||||
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
|
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calendari, calendarioDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
|
||||||
|
|
||||||
|
const d = (iso: string) => new Date(iso);
|
||||||
|
|
||||||
|
describe('calendarioDaMostrare', () => {
|
||||||
|
it('in vigore: mostra quello del periodo', () => {
|
||||||
|
expect(calendarioDaMostrare(d('2026-09-05T10:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-1-18' }, futuro: false,
|
||||||
|
});
|
||||||
|
expect(calendarioDaMostrare(d('2026-09-25T10:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-21-in-poi' }, futuro: false,
|
||||||
|
});
|
||||||
|
// `to: null` = a tempo indeterminato: fra un anno vale ancora quello.
|
||||||
|
expect(calendarioDaMostrare(d('2027-03-01T10:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-21-in-poi' }, futuro: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il link esiste prima che il primo orario cominci: ad agosto deve mostrare gli orari di
|
||||||
|
// settembre dichiarando che entrano dopo, non un "nessun calendario" che non dice niente.
|
||||||
|
it('prima dell\'inizio: mostra il prossimo, marcato come futuro', () => {
|
||||||
|
expect(calendarioDaMostrare(d('2026-08-17T10:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-1-18' }, futuro: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 19 e 20 settembre non sono coperti da nessuno dei due fogli del cliente. Il buco è vero e
|
||||||
|
// resta dichiarato qui: se un giorno verrà colmato, questo test lo dirà.
|
||||||
|
it('nei due giorni scoperti (19-20 settembre) mostra il prossimo', () => {
|
||||||
|
expect(calendarioDaMostrare(d('2026-09-19T10:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-21-in-poi' }, futuro: true,
|
||||||
|
});
|
||||||
|
expect(calendarioDaMostrare(d('2026-09-20T23:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-21-in-poi' }, futuro: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confini al secondo, in ora italiana', () => {
|
||||||
|
expect(calendarioDaMostrare(d('2026-09-18T23:59:59+02:00'))?.cal.id).toBe('settembre-1-18');
|
||||||
|
expect(calendarioDaMostrare(d('2026-09-21T00:00:00+02:00'))).toMatchObject({
|
||||||
|
cal: { id: 'settembre-21-in-poi' }, futuro: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coerenza dei calendari trascritti', () => {
|
||||||
|
for (const c of calendari) {
|
||||||
|
describe(c.id, () => {
|
||||||
|
it('la finestra è leggibile e ha un verso', () => {
|
||||||
|
expect(Number.isNaN(Date.parse(c.from))).toBe(false);
|
||||||
|
if (c.to !== null) {
|
||||||
|
expect(Number.isNaN(Date.parse(c.to))).toBe(false);
|
||||||
|
expect(Date.parse(c.to)).toBeGreaterThan(Date.parse(c.from));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le fasce orarie sono uniche e in ordine crescente', () => {
|
||||||
|
const ore = c.righe.map((r) => r.ora);
|
||||||
|
expect(new Set(ore).size, 'fascia ripetuta').toBe(ore.length);
|
||||||
|
const minuti = ore.map((o) => {
|
||||||
|
const [h, m] = o.split('.');
|
||||||
|
return Number(h) * 60 + Number(m);
|
||||||
|
});
|
||||||
|
expect(minuti.every((v, i) => i === 0 || v > minuti[i - 1]), `fasce fuori ordine: ${ore.join(' ')}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ogni cella sta su un giorno esistente e porta una disciplina nota', () => {
|
||||||
|
for (const r of c.righe) {
|
||||||
|
for (const [giorno, disciplina] of Object.entries(r.celle)) {
|
||||||
|
expect(GIORNI, `${r.ora}: giorno ${giorno} inesistente`).toContain(giorno as Giorno);
|
||||||
|
expect(DISCIPLINE, `${r.ora} ${giorno}: disciplina ${disciplina} sconosciuta`).toContain(disciplina!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Una lezione in un giorno di chiusura è la contraddizione che nessuno nota leggendo la
|
||||||
|
// griglia: la colonna è barrata e la cella ci finisce dentro lo stesso.
|
||||||
|
it('nessuna lezione cade in un giorno di chiusura', () => {
|
||||||
|
for (const r of c.righe) {
|
||||||
|
for (const g of c.chiusi) {
|
||||||
|
expect(r.celle[g], `${r.ora}: lezione di ${g}, che è chiuso`).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le note dei giorni si riferiscono a giorni aperti', () => {
|
||||||
|
for (const g of Object.keys(c.noteGiorno ?? {}) as Giorno[]) {
|
||||||
|
expect(GIORNI).toContain(g);
|
||||||
|
expect(c.chiusi, `nota su ${g}, che è dichiarato chiuso`).not.toContain(g);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user