diff --git a/package-lock.json b/package-lock.json index 4d9498b..1d83a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.11.1", "nodemailer": "^9.0.3", + "pdfkit": "^0.19.1", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1", @@ -32,6 +33,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/nodemailer": "^8.0.1", + "@types/pdfkit": "^0.17.6", "@types/sanitize-html": "^2.16.1", "marked": "^18.0.7", "typescript": "^6.0.3", @@ -1964,6 +1966,30 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -2387,6 +2413,15 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tiptap/core": { "version": "3.27.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", @@ -2979,6 +3014,16 @@ "@types/node": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -3583,6 +3628,24 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -3751,6 +3814,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4163,6 +4235,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -4449,7 +4527,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-string-truncated-width": { @@ -4534,6 +4611,23 @@ "fontkitten": "^1.0.2" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/fontkitten": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", @@ -4889,6 +4983,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5222,6 +5322,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/linkifyjs": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", @@ -5692,6 +5811,12 @@ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -5712,6 +5837,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -5736,6 +5875,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -6216,6 +6363,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/retext-smartypants": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", @@ -6782,8 +6935,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -6853,6 +7005,32 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index fe4002d..a720fe9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.11.1", "nodemailer": "^9.0.3", + "pdfkit": "^0.19.1", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1", @@ -38,6 +39,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/nodemailer": "^8.0.1", + "@types/pdfkit": "^0.17.6", "@types/sanitize-html": "^2.16.1", "marked": "^18.0.7", "typescript": "^6.0.3", diff --git a/scripts/testi-17-agosto.mjs b/scripts/testi-17-agosto.mjs new file mode 100644 index 0000000..e491ee1 --- /dev/null +++ b/scripts/testi-17-agosto.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Porta nel database i testi decisi dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf). + * + * Perché serve uno script e non basta il deploy: i testi mostrati dal sito vivono in + * `content_blocks`, e `resolveContent` legge **prima** il database e solo dopo il seed nel + * codice. Un tag già presente in tabella continua quindi a mostrare il vecchio valore anche + * dopo che il seed è stato aggiornato — in produzione sono 19 righe. + * + * Perché una tantum e non una migrazione all'avvio: una riscrittura a ogni restart + * rimetterebbe le mani su testi che il cliente può cambiare dal pannello. Qui si passa una + * volta, si guarda cosa è cambiato, e poi quei testi tornano suoi. + * + * Uso: node scripts/testi-17-agosto.mjs [percorso.db] [--dry-run] + */ +import Database from 'better-sqlite3'; + +/** + * "Small Group" diventa "Personalized Training Group", **con gli articoli giusti**. + * + * Una sostituzione meccanica produrrebbe "Lo Personalized Training Group" e "gli + * personalized training group": le due frasi che portano un articolo si correggono per + * intero, prima. Il resto è la sostituzione del nome, che resta capitalizzato come nome + * proprio anche dove prima era minuscolo ("in formula small group" → "in formula + * Personalized Training Group"). + * + * Idempotente: al secondo giro non c'è più niente da sostituire. + */ +export function rinominaSmallGroup(value) { + return value + .replace(/\bLo Small Group\b/g, 'Il Personalized Training Group') + .replace(/\bgli small groups?\b/gi, 'i Personalized Training Group') + .replace(/\bsmall groups?\b/gi, 'Personalized Training Group'); +} + +/** + * I testi riscritti per intero, tag per tag: il cliente ne ha dettato il contenuto nuovo, + * quindi non c'è una parola da sostituire ma una frase da rimpiazzare. + * + * Sono solo i tag che in produzione esistono già con il valore vecchio. Quelli nuovi (la + * sezione «Check-up Avanzato», gli eventi, la pagina di scelta) non stanno in tabella e li + * serve il seed dal codice, senza bisogno di scriverli qui. + */ +export const TESTI = { + 'service.profilazione.title': 'Check-up', + 'service.profilazione.subtitle': 'Base e avanzato', + 'service.profilazione.excerpt': + 'Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — un’analisi completa e scientifica che guida le nostre scelte.', + 'footer.services.4.label': 'Check-up', + 'services.meta.description': + 'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.', +}; + +/** I cambiamenti da fare, con il prima e il dopo. Non tocca niente. */ +export function calcola(db) { + const righe = db.prepare('SELECT tag, value FROM content_blocks').all(); + return righe + .map((r) => ({ + tag: r.tag, + prima: r.value, + dopo: r.tag in TESTI ? TESTI[r.tag] : rinominaSmallGroup(r.value), + })) + .filter((c) => c.prima !== c.dopo); +} + +export function applica(db, { dryRun = false } = {}) { + const cambi = calcola(db); + if (!dryRun && cambi.length) { + const upd = db.prepare( + "UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = 'testi-17-agosto' WHERE tag = ?", + ); + db.transaction(() => { for (const c of cambi) upd.run(c.dopo, c.tag); })(); + } + return cambi; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const path = args.find((a) => !a.startsWith('--')) ?? process.env.DB_PATH ?? 'data/insanitylab.db'; + const db = new Database(path); + const cambi = applica(db, { dryRun }); + for (const c of cambi) console.log(`${c.tag}\n - ${c.prima}\n + ${c.dopo}\n`); + console.log(`${cambi.length} contenuti ${dryRun ? 'da aggiornare (dry-run, niente scritto)' : 'aggiornati'} in ${path}`); + db.close(); +} diff --git a/src/components/CalendarioModal.astro b/src/components/CalendarioModal.astro new file mode 100644 index 0000000..248f51b --- /dev/null +++ b/src/components/CalendarioModal.astro @@ -0,0 +1,242 @@ +--- +// Popup a pieno schermo con gli orari delle class in Sala Piccola. +// +// I calendari sono ricostruiti dal dato (`data/calendari.ts`) e non sono le immagini ricevute +// dal cliente: si leggono da telefono, si scaricano in PDF, e per correggere una cella non +// serve rifare uno screenshot. +// +// Si vedono tutti gli orari non ancora scaduti, uno sotto l'altro: chi guarda ad agosto vuole +// sapere com'è settembre per intero. Quelli passati spariscono da soli. +// +// Si apre da qualunque elemento con `data-apri-calendario` nella stessa pagina. +import { calendariDaMostrare, avvisiAttivi, GIORNI, DISCIPLINE, type Calendario, type Giorno } from '../data/calendari'; +import { t } from '../lib/content'; + +const adesso = new Date(); +const mostrati = calendariDaMostrare(adesso); +// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate. +const avvisi = avvisiAttivi(adesso); + +const dataDi = (iso: string) => new Date(iso).toLocaleDateString('it-IT', { + day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome', +}); +// Legenda per griglia: elencare discipline che lì non compaiono farebbe cercare lezioni +// che quel periodo non ha. +const disciplinePresenti = (cal: Calendario) => + DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d))); +--- +{mostrati.length > 0 && ( + +
+
+
+

{mostrati[0].cal.sala}

+

Calendario delle class

+
+ +
+ + {avvisi.length > 0 && ( + + )} + +
+ {mostrati.map(({ cal, stato }) => ( +
+
+

{cal.titolo}

+ + {stato === 'in-corso' ? 'In vigore' : `${t('calendario.futuro-label')} ${dataDi(cal.from)}`} + +
+ + + + + + {GIORNI.map((g) => ( + + ))} + + + + {cal.righe.map((r) => ( + + + {GIORNI.map((g: Giorno) => { + const chiuso = cal.chiusi.includes(g); + const lezione = r.celle[g]; + return ( + + ); + })} + + ))} + +
Ora + {g} + {cal.noteGiorno?.[g] && {cal.noteGiorno[g]}} +
{r.ora} + {chiuso ? {t('calendario.chiuso-label')} : lezione ?? ''} +
+ +
    + {disciplinePresenti(cal).map((d) => ( +
  • {d}
  • + ))} +
+
+ ))} +
+ + +
+
+)} + + + + diff --git a/src/components/home/TrainingCards.astro b/src/components/home/TrainingCards.astro index 8a710e5..91ee789 100644 --- a/src/components/home/TrainingCards.astro +++ b/src/components/home/TrainingCards.astro @@ -4,6 +4,10 @@ import { trainings } from '../../data/trainings'; import T from '../content/T.astro'; import TImg from '../content/TImg.astro'; import { t } from '../../lib/content'; +// `items` permette di mostrare solo una parte dei training (le viste di /training): +// senza, la fascia di card resterebbe l'elenco intero sopra ai blocchi filtrati. +interface Props { items?: readonly (typeof trainings)[number][] } +const { items = trainings } = Astro.props; 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; const tones = ['taupe', 'tan', 'dark', 'charcoal']; @@ -11,7 +15,7 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal']; const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1; ---
- {trainings.map((tr, i) => { + {items.map((tr, i) => { const img = imgOf(tr.image); const card = (
diff --git a/src/data/calendari.ts b/src/data/calendari.ts new file mode 100644 index 0000000..a42103f --- /dev/null +++ b/src/data/calendari.ts @@ -0,0 +1,188 @@ +// 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 sono vuote: nel foglio ricevuto le due celle erano coperte + // dal riquadro "Opzioni Incolla" di Excel, e il 17/08/2026 Adriano ha confermato che + // lì non c'è niente. Il vuoto qui è una risposta, non una lettura mancata. + { 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' } }, + ], + }, +]; + +/** + * Giornate che non stanno in nessuna griglia: eventi e chiusure. + * + * Il 19 e il 20 settembre non compaiono in nessuno dei due fogli, e la ragione non è una + * dimenticanza — il sabato c'è la presentazione della nuova apertura e la domenica si sta + * chiusi (detto da Adriano il 17/08/2026). Scritto qui, quel vuoto smette di sembrare un + * errore di trascrizione e diventa un'informazione per chi legge gli orari. + */ +export interface AvvisoCalendario { + id: string; + /** `null` = visibile da subito. */ + from: string | null; + /** Ultimo istante in cui ha senso mostrarlo: dopo, sparisce da solo. */ + to: string; + quando: string; + testo: string; +} + +export const avvisi: AvvisoCalendario[] = [ + { + id: 'presentazione-19-settembre', + from: null, + to: '2026-09-19T23:59:59+02:00', + quando: 'Sabato 19 settembre', + testo: 'Presentazione della nuova apertura: nessuna class in programma.', + }, + { + id: 'chiusura-20-settembre', + from: null, + to: '2026-09-20T23:59:59+02:00', + quando: 'Domenica 20 settembre', + testo: 'Chiuso.', + }, +]; + +/** Gli avvisi ancora validi, in ordine di scadenza: i più imminenti per primi. */ +export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): AvvisoCalendario[] { + const t = now.getTime(); + return list + .filter((a) => (a.from === null || t >= Date.parse(a.from)) && t <= Date.parse(a.to)) + .sort((x, y) => Date.parse(x.to) - Date.parse(y.to)); +} + +/** + * Le schede servizio da cui si apre il calendario della sala. + * + * Sono le due class che si fanno davvero: la Performance Class è uscita di scena il + * 17/08/2026 (il cliente non la eroga più) e con lei la sua scheda. + */ +export const PAGINE_CON_CALENDARIO = ['holistic-class', 'reformer-class']; + +/** + * Le card della griglia /services che portano il bottone del calendario. + * + * È un elenco a parte, e non un doppione del precedente: nella griglia la card si chiama + * «Class» ma ha slug `performance-class`, e da lì si va alla vista Training con Holistic e + * Reformer. Il calendario è lo stesso, il punto da cui si apre no. + */ +export const CARD_CON_CALENDARIO = ['performance-class']; + +export interface CalendarioMostrato { + cal: Calendario; + stato: 'in-corso' | 'futuro'; +} + +/** + * I calendari da mostrare: quello in vigore e quelli che verranno, in ordine di partenza. + * + * Si vedono **tutti insieme**, non solo quello del momento: chi guarda a fine agosto vuole + * sapere com'è settembre per intero, e vedere una griglia sola gli farebbe credere che sia + * quella per sempre. Ognuna porta il proprio stato, così l'orario in vigore adesso non si + * confonde con quello che comincerà dopo. + * + * Gli orari **scaduti spariscono da soli**: dal 21 settembre resta il solo foglio valido, e + * nessuno rischia di leggere le lezioni di un periodo finito. + */ +export function calendariDaMostrare(now: Date, list: Calendario[] = calendari): CalendarioMostrato[] { + const t = now.getTime(); + return list + .filter((c) => c.to === null || t <= Date.parse(c.to)) + .sort((a, b) => Date.parse(a.from) - Date.parse(b.from)) + .map((cal) => ({ cal, stato: t >= Date.parse(cal.from) ? 'in-corso' : 'futuro' })); +} diff --git a/src/data/content-seed.ts b/src/data/content-seed.ts index d55f900..b3f8d1f 100644 --- a/src/data/content-seed.ts +++ b/src/data/content-seed.ts @@ -222,7 +222,7 @@ export const contentSeed: SeedEntry[] = [ T('about.storylab.title', 'Storylab'), T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'), T('about.timeline.label', 'INSANITYLAB 2021-2024', []), - T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."), + T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come i Personalized Training Group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."), T('about.timeline.stage1.label', 'INSANITYLAB 2025', []), T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), T('about.timeline.stage2.label', 'INSANITYLAB 2026', []), @@ -231,7 +231,7 @@ export const contentSeed: SeedEntry[] = [ T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'), // --- Services: lista (/services) --- T('services.meta.title', 'Servizi', []), - T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.', []), + T('services.meta.description', 'I servizi InsanityLab: Personal Training, Class, Coaching, Check-up, Osteopatia e Nutrizione.', []), T('services.hero.title', 'Servizi', []), T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."), T('services.card.more-label', 'Per saperne di più →', []), @@ -247,6 +247,13 @@ export const contentSeed: SeedEntry[] = [ T(`service.${p.slug}.faq.${i + 1}.q`, item.q), T(`service.${p.slug}.faq.${i + 1}.a`, item.a), ]), + ...(p.extra + ? [ + T(`service.${p.slug}.extra.title`, p.extra.title), + T(`service.${p.slug}.extra.body`, p.extra.body), + ...p.extra.items.map((it, i) => T(`service.${p.slug}.extra.item.${i + 1}`, it)), + ] + : []), I(`service.${p.slug}.image`), ]), @@ -277,8 +284,9 @@ export const contentSeed: SeedEntry[] = [ // --- Training: meta, hero, intro, trial --- T('training.meta.title', 'Training', []), - T('training.meta.description', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []), + T('training.meta.description', 'One to One, Personalized Training Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.', []), T('training.hero.title', 'training', []), + T('training.tutti.cta-label', 'Vedi tutti i training', []), T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'), // --- Contact --- @@ -293,6 +301,28 @@ export const contentSeed: SeedEntry[] = [ T('contact.info.email-label', 'E-mail'), T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'), + // --- Promo ed eventi: scelta (/promo-eventi) --- + T('promo-eventi.meta.title', 'Promo ed eventi', []), + T('promo-eventi.meta.description', 'Le promozioni in corso e gli eventi di InsanityLab, a partire dall’inaugurazione del nuovo spazio.', []), + T('promo-eventi.hero.title', 'Promo ed eventi', []), + T('promo-eventi.intro', 'Scegli cosa vuoi vedere: le promozioni attive in questo momento oppure gli eventi in programma al centro.'), + T('promo-eventi.promo.label', 'Promo'), + T('promo-eventi.promo.desc', 'I pacchetti e le offerte del periodo, con prezzi e condizioni.'), + T('promo-eventi.promo.nessuna', 'Nessuna promozione in corso', []), + T('promo-eventi.promo.cta', 'Vedi le promo', []), + T('promo-eventi.eventi.label', 'Eventi'), + T('promo-eventi.eventi.desc', 'Le date da segnare: aperture, presentazioni e appuntamenti in sede.'), + T('promo-eventi.eventi.cta', 'Vedi gli eventi', []), + + // --- Eventi (/promo-eventi/eventi) --- + T('eventi.meta.title', 'Eventi', []), + T('eventi.meta.description', 'Gli eventi InsanityLab: inaugurazione del nuovo spazio il 19 settembre 2026, con aperitivo e dj set.', []), + T('eventi.hero.title', 'Eventi', []), + T('eventi.inaugurazione.title', 'Inaugurazione — 19 settembre 2026'), + T('eventi.inaugurazione.body', 'Apriamo le porte del nuovo spazio con un aperitivo e dj set. Sarà l’occasione per conoscere lo staff, visitare la struttura e prenotare una prova gratuita dei nostri servizi. Il programma completo della giornata sarà pubblicato a breve.'), + T('eventi.cta.label', 'Scrivici su WhatsApp', []), + T('eventi.torna.label', '← Torna a promo ed eventi', []), + // --- 404 --- T('notfound.meta.title', 'Pagina non trovata', []), T('notfound.eyebrow', 'Errore 404', []), @@ -327,10 +357,10 @@ export const contentSeed: SeedEntry[] = [ T('promo.card.1.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'), T('promo.card.1.feature.3', 'PT dedicato, presente a ogni sessione'), T('promo.card.1.feature.4', 'In-sanity plan, il tuo programma personalizzato'), - // Card 2 — Small Group (in evidenza) + // Card 2 — Personalized Training Group (in evidenza) T('promo.card.2.badge', 'Più scelto', []), T('promo.card.2.eyebrow', 'Personal training', []), - T('promo.card.2.title', 'Small Group'), + T('promo.card.2.title', 'Personalized Training Group'), T('promo.card.2.price', '125 €', []), T('promo.card.2.price-old', '220 €', []), T('promo.card.2.unit', '5 incontri', []), @@ -373,12 +403,12 @@ export const contentSeed: SeedEntry[] = [ // presenti nei DB non verrebbero riscritti dall'INSERT OR IGNORE del seed). T('promo.faq.0.q', 'Come faccio ad acquistare i pacchetti?'), H('promo.faq.0.a', 'Clicca sull\'icona del pacchetto che desideri acquistare 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 quello 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('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'), + T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Personalized Training Group?'), H('promo.faq.1.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta avvenuto con successo l\'acquisto, potrai contattarci scrivendoci su WhatsApp ({{whatsapp}}) per prenotare lo screening iniziale ovvero il colloquio conoscitivo per la definizione dei tuoi obiettivi. Verrai ricontattato subito tramite messaggio o chiamata! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'), T('promo.faq.2.q', 'Come funziona il percorso Reformer o Holistic Class?'), H('promo.faq.2.a', 'Per acquistare il pacchetto online segui le istruzioni riportate nella risposta della prima domanda. Una volta scaricata l\'app Bsport e acquistato il pacchetto potrai procedere a selezionare autonomamente la class che preferisci nel giorno e nell\'orario che desideri. Gli orari delle class saranno pubblicati entro Settembre. In caso di problemi contattaci su WhatsApp ({{whatsapp}})! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.'), T('promo.faq.3.q', 'Posso usare gli incontri su servizi diversi?'), - H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (One to One, Small Group, Reformer o Holistic Class). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'), + H('promo.faq.3.a', 'Ogni pacchetto è dedicato a un singolo servizio (One to One, Personalized Training Group, Reformer o Holistic Class). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.'), T('promo.faq.4.q', 'Posso regalare un pacchetto a qualcun altro?'), H('promo.faq.4.a', 'Sì. Puoi acquistare il pacchetto a tuo nome — per la fatturazione — e farlo utilizzare a un\'altra persona: perfetto se vuoi fare un regalo a un amico o un familiare. Ti basterà comunicarci chi userà gli incontri quando ci scriverai su WhatsApp ({{whatsapp}}) per organizzare le sessioni.'), T('promo.faq.5.q', 'Il pagamento è sicuro?'), @@ -386,6 +416,92 @@ 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. + 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/data/services.ts b/src/data/services.ts index 5b68b8d..5338ef7 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -13,12 +13,24 @@ export interface Service { /** false = pagina dettaglio esiste ma la voce non compare nella griglia /services * (usata come approfondimento dai riquadri Training: Rehab, Holistic, Reformer). */ grid?: boolean; + /** Dove porta la card (foto e "Per saperne di più"). Assente = la scheda del servizio. + * Serve per le card che instradano a una vista di /training invece che a una scheda. */ + href?: string; + /** false = nessuna pagina dettaglio: /services/ risponde 404. + * La voce resta in griglia con il proprio `href`. */ + page?: boolean; + /** Sezione in coda alla scheda, oltre alla descrizione principale. + * Tag: service..extra.title | .body | .item.N */ + extra?: { title: string; body: string; items: string[] }; } export const services: Service[] = [ { slug: "one-to-one", title: "One to One", + // La card di /services porta alla vista Training con One to One, Personalized Training + // Group e Rehab: è come il cliente vuole instradare chi cerca l'allenamento seguito. + href: "/training/one-to-one", heroTitle: "Personal Training", subtitle: "Allenamento con un trainer dedicato", excerpt: "L’allenamento One to One di InsanityLab è un percorso su misura, seguito da un professionista dedicato, per raggiungere in modo sicuro ed efficace obiettivi di forma fisica, performance e benessere.", @@ -33,13 +45,13 @@ export const services: Service[] = [ }, { slug: "small-group", - title: "Small Group", + title: "Personalized Training Group", subtitle: "Mini gruppi (max 4 persone) con trainer dedicato", - excerpt: "Lo Small Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.", - longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.", + excerpt: "Il Personalized Training Group di InsanityLab è un allenamento in mini gruppi da massimo 4 persone con un trainer dedicato, che unisce attenzione personalizzata, motivazione e condivisione.", + longDescription: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione: programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme, senza rinunciare alla cura del singolo.", features: [], faq: [ - { q: "Quante persone ci sono in un Small Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." }, + { q: "Quante persone ci sono in un Personalized Training Group?", a: "Massimo 4 persone per gruppo, così il trainer può seguire ciascuno con attenzione." }, { q: "Con quale frequenza ci si allena?", a: "La frequenza viene definita insieme al trainer in base a obiettivi, disponibilità e livello di partenza." }, { q: "È adatto a chi parte da zero?", a: "Sì: il percorso è costruito sul livello dei partecipanti, con progressioni graduali e supervisione costante." }, ], @@ -47,8 +59,14 @@ export const services: Service[] = [ grid: false, }, { + // La Performance Class non si fa più (cliente, 17/08/2026): la scheda che la spiegava + // sparisce (`page: false` → /services/performance-class risponde 404) mentre la card + // "Class" resta in griglia e porta alla vista Training con Holistic e Reformer. + // Il pricing è caduto con la scheda: era l'unico servizio ad averlo. slug: "performance-class", title: "Class", + href: "/training/class", + page: false, subtitle: "Gruppo max 8 persone", excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.", longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.", @@ -61,7 +79,7 @@ export const services: Service[] = [ { q: "Che tipo di risultati o miglioramenti si ottengono?", a: "Miglioramento di forza, composizione corporea e performance, con risultati concreti e costanti nel tempo." }, { q: "Qual è l’intensità del lavoro?", a: "Alternata: fasi di bassa, media e alta intensità all’interno della stessa sessione, adattate al livello dei partecipanti." }, ], - hasPricing: true, + hasPricing: false, }, { slug: "coaching", @@ -81,9 +99,9 @@ export const services: Service[] = [ // NB: sostituisce "Pilates Flow" (il Pilates confluisce nella Holistic Class in Training). // Copy BOZZA basata sul metodo ISL (profilazione fisica) — da confermare col cliente. slug: "profilazione", - title: "Profilazione", - subtitle: "Check-up e valutazione fisica", - excerpt: "La profilazione fisica è il punto di partenza del metodo InsanityLab: una valutazione completa che definisce lo stato di salute iniziale e le priorità del percorso.", + title: "Check-up", + subtitle: "Base e avanzato", + excerpt: "Il Check-up Base fotografa la tua condizione di partenza e rappresenta il punto di inizio del metodo InsanityLab. Attraverso la profilazione e la valutazione fisica definiamo lo stato di salute iniziale e le priorità del percorso.\n\nIl Check-up Avanzato va oltre: con macchinari di ultima generazione misuriamo composizione corporea, parametri vitali, spirometria, HRV — un’analisi completa e scientifica che guida le nostre scelte.", longDescription: "Attraverso check-up, plicometria e analisi antropometriche misuriamo composizione corporea, postura e stato di forma. I dati raccolti guidano la costruzione di un programma personalizzato e permettono di monitorare i progressi nel tempo.", features: ["Check-up fisico iniziale", "Plicometria e analisi antropometriche", "Definizione delle priorità del percorso", "Monitoraggio periodico dei progressi"], faq: [ @@ -91,6 +109,19 @@ export const services: Service[] = [ { q: "Che cosa comprende?", a: "Check-up fisico, plicometria e analisi antropometriche; alcune misurazioni avanzate sono disponibili su richiesta." }, ], hasPricing: false, + // Il testo della scheda descrive il Check-up Base; l'Avanzato è la parte aggiunta il + // 17/08/2026 su richiesta del cliente, con la Wellness Tower e i sensori HRV. + extra: { + title: "Check-up Avanzato", + body: "Con la Wellness Tower, tecnologia di ultima generazione, andiamo oltre: bioimpedenza, scansione posturale 3D e spirometria restituiscono un quadro completo e oggettivo del tuo corpo. A completare il quadro, misuriamo anche l’HRV attraverso sensori dedicati — un dato chiave per capire come il tuo corpo recupera e si adatta al carico di lavoro. Ogni dato raccolto guida scelte più precise nella costruzione del programma e nel monitoraggio dei progressi.", + items: [ + "Composizione corporea di precisione (massa grassa, magra, metabolismo basale)", + "Scansione posturale 3D e analisi di simmetria", + "Spirometria e parametri cardiovascolari", + "Analisi HRV tramite sensori dedicati", + "Report immediato, confrontabile nel tempo", + ], + }, }, { // NB: sostituisce "Rehab" nella pagina Servizi (il Rehab si sposta in Training). diff --git a/src/data/site.ts b/src/data/site.ts index d8e415c..073088a 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -21,17 +21,17 @@ export const site = { ], footerTraining: [ { label: "One to one", href: "/training#one-to-one" }, - { label: "Small group", href: "/training#small-groups" }, + { label: "Personalized Training Group", href: "/training#small-groups" }, { label: "Rehab", href: "/training#rehab" }, { label: "Holistic class", href: "/training#holistic-class" }, { label: "Reformer class", href: "/training#reformer-class" }, { label: "Coaching", href: "/training#coaching" }, ], footerServices: [ - { label: "Personal Training", href: "/services/personal-training" }, - { label: "Class", href: "/services/performance-class" }, + { label: "Personal Training", href: "/training/one-to-one" }, + { label: "Class", href: "/training/class" }, { label: "Coaching", href: "/services/coaching" }, - { label: "Profilazione", href: "/services/profilazione" }, + { label: "Check-up", href: "/services/profilazione" }, { label: "Osteopatia", href: "/services/osteopatia" }, { label: "Nutrizione", href: "/services/nutrition" }, ], diff --git a/src/data/trainings.ts b/src/data/trainings.ts index d3ab22c..1ab0ba6 100644 --- a/src/data/trainings.ts +++ b/src/data/trainings.ts @@ -1,8 +1,35 @@ export const trainings = [ { id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Scopri", href: "/services/one-to-one" } }, - { id: "small-groups", title: "Small Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } }, + { id: "small-groups", title: "Personalized Training Group", caption: "Mini gruppi (max 4 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 4 persone con un trainer dedicato. Un’esperienza Personalized Training Group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Scopri", href: "/services/small-group" } }, { id: "rehab", title: "Rehab", caption: "Recupero funzionale attraverso il movimento guidato", image: "training-rehab", description: "Il Rehab InsanityLab accompagna il corpo nel recupero della sua funzionalità attraverso il movimento guidato: il ponte tra la fine del percorso medico-riabilitativo e il ritorno a una vita attiva piena, con la stessa cura scientifica ma in un contesto di allenamento.", cta: { label: "Scopri", href: "/services/rehab" } }, { id: "holistic-class", title: "Holistic Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-holistic-class", description: "Mobilità articolare, respirazione, equilibrio. Da sola o integrata nei tuoi allenamenti, adatta a tutti i livelli. Sessioni da 40 minuti che uniscono Pilates Flow, Mobility e Yoga.", cta: { label: "Scopri", href: "/services/holistic-class" } }, { id: "reformer-class", title: "Reformer Class", caption: "Classi fino a 5 persone con macchinari reformer", image: "training-reformer-class", description: "Core, postura e mobilità su macchina reformer. Lavoro a basso impatto sulle articolazioni, adatto a tutti i livelli. Lezioni da 40 minuti in classi fino a 5 persone.", cta: { label: "Scopri", href: "/services/reformer-class" } }, { id: "coaching", title: "Coaching", caption: "Programma su misura e monitoraggio, anche online", image: "training-coaching", description: "Con il coaching hai un programma di allenamento su misura e un monitoraggio costante, ovunque ti alleni: in palestra, a casa o in viaggio. Il percorso sviluppa autonomia e continuità, adattandosi al tuo stile di vita (anche online, formula Fit Remote).", cta: { label: "Scopri", href: "/services/coaching" } }, ] as const; + +/** + * Le viste di /training: la pagina mostra tutti i training, oppure il solo sottoinsieme + * chiesto dal cliente (documento MODIFICHE_PER_SITO.pdf del 17/08/2026). + * + * Da /services la card «One to One» porta a One to One / Personalized Training Group / Rehab, + * la card «Class» a Holistic Class / Reformer Class: chi clicca cerca quella famiglia di + * servizi, e vedersi comparire l'elenco intero costringe a cercare di nuovo. + */ +export const VISTE_TRAINING: Record = { + 'one-to-one': { titolo: 'Allenamento seguito', blocchi: ['one-to-one', 'small-groups', 'rehab'] }, + class: { titolo: 'Class', blocchi: ['holistic-class', 'reformer-class'] }, +}; + +/** + * I training da mostrare per una vista. `undefined` = pagina intera. + * + * Una vista sconosciuta restituisce `null`, e la pagina risponde 404: mostrare l'elenco + * completo al posto di un filtro sbagliato farebbe passare per buono un indirizzo rotto — + * il link continuerebbe a "funzionare" mentre porta altrove. + */ +export function trainingDaMostrare(vista?: string): typeof trainings[number][] | null { + if (!vista) return [...trainings]; + const v = VISTE_TRAINING[vista]; + if (!v) return null; + return v.blocchi.map((id) => trainings.find((t) => t.id === id)!).filter(Boolean); +} diff --git a/src/lib/calendario-pdf.ts b/src/lib/calendario-pdf.ts new file mode 100644 index 0000000..4bea433 --- /dev/null +++ b/src/lib/calendario-pdf.ts @@ -0,0 +1,187 @@ +import PDFDocument from 'pdfkit'; +import { GIORNI, type AvvisoCalendario, type Calendario, type Disciplina, type Giorno } from '../data/calendari'; + +// PDF del calendario, disegnato dal dato con PDFKit. +// +// Non è la stampa del browser: è un file vero, che si scarica da un link e si può girare su +// WhatsApp. Serve un documento, non uno screenshot della pagina — chi lo riceve non ha il +// sito aperto davanti. +// +// Niente browser headless di mezzo: Chromium in un container da 2 vCPU costa centinaia di +// megabyte e un processo per ogni richiesta, per una griglia di sette colonne. + +/** Colori delle discipline, con la barra a sinistra più scura del fondo (come in pagina). */ +const COLORI: Record = { + REFORMER: { fondo: '#ece5d7', barra: '#9c8b70' }, + PILATES: { fondo: '#f6efe0', barra: '#cbb98f' }, + YOGA: { fondo: '#e3eadf', barra: '#8ba57f' }, + MOBILITY: { fondo: '#f0e3e3', barra: '#b78e8e' }, +}; +const C_TESTO = '#2b2b2b'; +const C_TENUE = '#8a8a8a'; +const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni uguali in fasce + // consecutive devono restare due, non sembrare un blocco unico +const C_ACCENTO = '#9c8b70'; +const C_ALT = '#f5f3f0'; + +/** Un solo orario: il nome porta il suo id. Più orari insieme: il nome è generico. */ +export const nomeFilePdf = (cals: Calendario[]): string => + cals.length === 1 ? `insanitylab-calendario-${cals[0].id}.pdf` : 'insanitylab-calendario-class.pdf'; + +/** + * Genera il PDF e restituisce il file completo: **un orario per pagina**. + * + * Ogni griglia sta in una pagina intera perché spezzarla renderebbe illeggibile proprio ciò + * che si va a cercare — l'incrocio fra un giorno e un'ora; e due orari sullo stesso foglio si + * confonderebbero fra loro. Le giornate fuori griglia si ripetono su ogni pagina: un foglio + * stampato da solo deve bastare a sé stesso. + */ +export function generaCalendariPdf(cals: Calendario[], avvisi: AvvisoCalendario[] = []): Promise { + const doc = new PDFDocument({ + size: 'A4', + layout: 'landscape', + margin: 32, + info: { + // Il titolo è quello che compare nella scheda del lettore PDF e nel nome suggerito dai + // telefoni: con un orario solo dice quale, con più orari dice il periodo coperto. + Title: cals.length === 1 + ? `InsanityLab — ${cals[0].sala}, ${cals[0].titolo}` + : `InsanityLab — Calendario delle class (${cals.map((c) => c.titolo).join('; ')})`, + Author: 'InsanityLab', + Subject: 'Calendario delle class', + }, + }); + + const pezzi: Buffer[] = []; + const finito = new Promise((risolvi, rifiuta) => { + doc.on('data', (p: Buffer) => pezzi.push(p)); + doc.on('end', () => risolvi(Buffer.concat(pezzi))); + doc.on('error', rifiuta); + }); + + cals.forEach((cal, indice) => { + if (indice > 0) doc.addPage(); + disegnaPagina(doc, cal, avvisi); + }); + + doc.end(); + return finito; +} + +/** Una pagina: testata, griglia, legenda, giornate fuori griglia, piè di pagina. */ +function disegnaPagina(doc: PDFKit.PDFDocument, cal: Calendario, avvisi: AvvisoCalendario[]): void { + const x0 = doc.page.margins.left; + const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right; + + // --- Testata --- + doc.font('Helvetica-Bold').fontSize(16).fillColor(C_TESTO) + .text('INSANITYLAB', x0, doc.page.margins.top); + doc.font('Helvetica').fontSize(8).fillColor(C_ACCENTO) + .text(cal.sala.toUpperCase(), x0, doc.y + 2, { characterSpacing: 1.6 }); + doc.font('Helvetica-Bold').fontSize(12).fillColor(C_TESTO) + .text(cal.titolo, x0, doc.y + 4); + + // --- Griglia --- + const larghezzaOra = 52; + const larghezzaGiorno = (larghezzaUtile - larghezzaOra) / GIORNI.length; + const conNota = GIORNI.some((g) => cal.noteGiorno?.[g]); + const altezzaTestata = conNota ? 26 : 18; + let y = doc.y + 12; + + const xDi = (i: number) => x0 + larghezzaOra + i * larghezzaGiorno; + + // Intestazione delle colonne + doc.rect(x0, y, larghezzaUtile, altezzaTestata).fill(C_ALT); + doc.font('Helvetica-Bold').fontSize(7).fillColor(C_ACCENTO) + .text('ORA', x0 + 5, y + 6, { width: larghezzaOra - 10, characterSpacing: 1 }); + GIORNI.forEach((g, i) => { + doc.font('Helvetica-Bold').fontSize(7).fillColor(cal.chiusi.includes(g) ? C_TENUE : C_TESTO) + .text(g, xDi(i) + 5, y + 6, { width: larghezzaGiorno - 10, characterSpacing: 1 }); + const nota = cal.noteGiorno?.[g]; + if (nota) { + doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE) + .text(nota, xDi(i) + 5, y + 15, { width: larghezzaGiorno - 8 }); + } + }); + y += altezzaTestata; + + // Righe: l'altezza si ricava dallo spazio che resta, così la griglia riempie la pagina + // qualunque sia il numero di fasce e non serve ritoccarla a mano quando ne cambiano. + // legenda + nota + piè di pagina, più una riga per ogni giornata fuori griglia: senza + // tenerne conto la griglia si allungherebbe fino a spingere gli avvisi in una seconda pagina. + const spazioSotto = 52 + avvisi.length * 11; + const altezzaRiga = Math.min( + 22, + (doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length, + ); + + // Prima passata: riempimenti e testi. + const yPrimaRiga = y; + for (const r of cal.righe) { + if (r.clou) doc.rect(x0, y, larghezzaOra, altezzaRiga).fill('#f7f2e6'); + doc.font('Helvetica-Bold').fontSize(7.5).fillColor(C_TESTO) + .text(r.ora, x0 + 5, y + altezzaRiga / 2 - 4, { width: larghezzaOra - 8 }); + + GIORNI.forEach((g: Giorno, i) => { + const x = xDi(i); + const chiuso = cal.chiusi.includes(g); + const lezione = r.celle[g]; + if (chiuso) { + doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill('#efece7'); + doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE) + .text('CHIUSO', x + 5, y + altezzaRiga / 2 - 3, { width: larghezzaGiorno - 8, characterSpacing: 0.8 }); + } else if (lezione) { + const c = COLORI[lezione]; + doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill(c.fondo); + doc.rect(x, y, 2.5, altezzaRiga).fill(c.barra); + doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C_TESTO) + .text(lezione, x + 7, y + altezzaRiga / 2 - 3.5, { width: larghezzaGiorno - 10, characterSpacing: 0.5 }); + } + }); + y += altezzaRiga; + } + + // Seconda passata: la griglia sopra i riempimenti. Tracciando le linee riga per riga, il + // riempimento della riga seguente copriva quella appena disegnata e due lezioni uguali una + // sotto l'altra si leggevano come un unico blocco lungo il doppio. + doc.lineWidth(0.4).strokeColor(C_BORDO); + for (let i = 0; i <= cal.righe.length; i++) { + const yy = yPrimaRiga + i * altezzaRiga; + doc.moveTo(x0, yy).lineTo(x0 + larghezzaUtile, yy).stroke(); + } + for (let i = 0; i <= GIORNI.length; i++) { + const x = i === 0 ? x0 + larghezzaOra : xDi(i); + doc.moveTo(x, yPrimaRiga - altezzaTestata).lineTo(x, y).stroke(); + } + doc.rect(x0, yPrimaRiga - altezzaTestata, larghezzaUtile, altezzaTestata + altezzaRiga * cal.righe.length).stroke(); + + // --- Legenda: solo le discipline che compaiono davvero in questa griglia --- + const presenti = (Object.keys(COLORI) as Disciplina[]) + .filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d))); + let xLeg = x0; + const yLeg = y + 12; + for (const d of presenti) { + doc.rect(xLeg, yLeg, 9, 9).fill(COLORI[d].fondo); + doc.rect(xLeg, yLeg, 2.5, 9).fill(COLORI[d].barra); + doc.font('Helvetica').fontSize(7).fillColor(C_TESTO) + .text(d, xLeg + 13, yLeg + 2, { width: 70 }); + xLeg += 13 + doc.widthOfString(d) + 18; + } + + // --- Giornate fuori griglia (eventi, chiusure) --- + let yAvvisi = yLeg + 16; + for (const a of avvisi) { + doc.rect(x0, yAvvisi - 1, 2, 9).fill(C_ACCENTO); + doc.font('Helvetica-Bold').fontSize(7).fillColor(C_TESTO) + .text(a.quando, x0 + 7, yAvvisi, { continued: true }) + .font('Helvetica').fillColor(C_TENUE).text(` — ${a.testo}`); + yAvvisi += 11; + } + + doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text( + "Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.", + x0, yAvvisi + 3, { width: larghezzaUtile }, + ); + doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO) + .text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' }); +} diff --git a/src/pages/calendario/[id].pdf.ts b/src/pages/calendario/[id].pdf.ts new file mode 100644 index 0000000..3bfedcb --- /dev/null +++ b/src/pages/calendario/[id].pdf.ts @@ -0,0 +1,41 @@ +import type { APIRoute } from 'astro'; +import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari'; +import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf'; + +export const prerender = false; + +// Il PDF degli orari, come file scaricabile. +// +// /calendario/tutti.pdf tutti gli orari validi, uno per pagina +// /calendario/settembre-1-18.pdf un orario solo +// +// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file +// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica +// coi clienti. +export const GET: APIRoute = async ({ params }) => { + // "tutti" segue la stessa regola della pagina: gli orari scaduti non ci sono. Un PDF che si + // porta dietro il periodo finito farebbe cercare lezioni che non esistono più. + const cals = params.id === 'tutti' + ? calendariDaMostrare(new Date()).map((m) => m.cal) + : calendari.filter((c) => c.id === params.id); + + // Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa + // credere che il calendario sia vuoto. + if (cals.length === 0) return new Response('Calendario inesistente.', { status: 404 }); + + // Le giornate fuori griglia stanno anche nel PDF: è il file che gira su WhatsApp, e chi lo + // riceve non ha davanti la pagina dove sono scritte. + const pdf = await generaCalendariPdf(cals, avvisiAttivi(new Date())); + return new Response(new Uint8Array(pdf), { + headers: { + 'Content-Type': 'application/pdf', + // `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una + // scheda e su telefono resterebbe lì, senza finire fra i file. + 'Content-Disposition': `attachment; filename="${nomeFilePdf(cals)}"`, + 'Content-Length': String(pdf.length), + // Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai + // scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero. + 'Cache-Control': 'public, max-age=3600', + }, + }); +}; diff --git a/src/pages/promo-eventi.astro b/src/pages/promo-eventi.astro deleted file mode 100644 index 2c9de55..0000000 --- a/src/pages/promo-eventi.astro +++ /dev/null @@ -1,260 +0,0 @@ ---- -import Base from '../layouts/Base.astro'; -import T from '../components/content/T.astro'; -import { t, resolveContent } from '../lib/content'; -// 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. -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]; -// 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'); ---- - -
-
- -
- - - - - - -
- -
- - - - -
- -
- -
- -
- -
- {cards.map((c) => ( -
- {c.featured && } - - -
- - -
- -
    - {Array.from({ length: c.features }, (_, i) => i + 1).map((m) => ( - - ))} -
- -
- ))} -
- -
- -
- - {faqs.map((n) => { - const a = resolveContent(`promo.faq.${n}.a`); - return ( -
-
+
-

-

- ); - })} -
- -
-
- - - - - - diff --git a/src/pages/promo-eventi/eventi.astro b/src/pages/promo-eventi/eventi.astro new file mode 100644 index 0000000..9907a4f --- /dev/null +++ b/src/pages/promo-eventi/eventi.astro @@ -0,0 +1,36 @@ +--- +import Base from '../../layouts/Base.astro'; +import PageHero from '../../components/PageHero.astro'; +import T from '../../components/content/T.astro'; +import { t } from '../../lib/content'; +export const prerender = false; + +// Gli eventi del centro. Oggi ce n'è uno solo — l'inaugurazione del 19 settembre — e il +// programma della giornata non c'è ancora: il testo lo dichiara invece di lasciare una +// pagina vuota, ed è editabile dal pannello, così il cliente lo completa senza un deploy. +const wa = t('global.whatsapp'); +const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`; +--- + + + +
+ +
+ + + 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/promo.astro b/src/pages/promo-eventi/promo.astro new file mode 100644 index 0000000..bf2ccc5 --- /dev/null +++ b/src/pages/promo-eventi/promo.astro @@ -0,0 +1,356 @@ +--- +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: +// il catalogo lo presenta bsport dopo l'accesso. +const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428'; +export const prerender = false; + +// 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 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 +// 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'); +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.'}> +
+
+ + {anteprima && ( +
+ Anteprima al {quando.toLocaleString('it-IT', { timeZone: 'Europe/Rome' })} — + {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 ? ( + +
+ + + + + + +
+ + {ed.launch && ( + +
+ + + + +
+ +
+ +
+
+ )} + +
+ +
+ {ed.cards.map((c) => ( +
+ {c.featured && } + + + {c.question && } +
+ + +
+ {c.unit && } +
    + {Array.from({ length: c.features }, (_, i) => i + 1).map((m) => ( + + ))} +
+ {c.desc && } + {c.note && ( +
+ + +
+ )} + +
+ ))} +
+ +
+ +
+ + {ed.faq.map((n) => { + const a = resolveContent(tg(`faq.${n}.a`)); + return ( +
+
+
+

+

+ ); + })} +
+
+ ) : ( +
+ + + +
+ )} + +
+
+ + + + + + diff --git a/src/pages/services/[slug].astro b/src/pages/services/[slug].astro index fdfa72f..e57bf97 100644 --- a/src/pages/services/[slug].astro +++ b/src/pages/services/[slug].astro @@ -7,13 +7,21 @@ 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 { 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; -const others = services.filter((p) => p.slug !== service.slug && p.grid !== false); +// 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 = 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); @@ -38,6 +46,15 @@ const tones = ['tan', 'taupe', 'dark']; {heroImg && ( )} + {service.extra && ( +
+ + +
    + {service.extra.items.map((_, i) => )} +
+
+ )}
{service.faq.map((_, i) => (
@@ -59,6 +76,16 @@ const tones = ['tan', 'taupe', 'dark']; ))} + {conCalendario && ( + + )}
@@ -95,12 +122,16 @@ const tones = ['tan', 'taupe', 'dark']; )} + + {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/calendari.test.ts b/tests/calendari.test.ts new file mode 100644 index 0000000..b37968b --- /dev/null +++ b/tests/calendari.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { avvisi, avvisiAttivi, calendari, calendariDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari'; + +const d = (iso: string) => new Date(iso); + +describe('calendariDaMostrare', () => { + const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`); + + // Si vedono tutti insieme, non solo quello del momento: chi guarda ad agosto vuole sapere + // com'è settembre per intero, e una griglia sola gli farebbe credere che valga per sempre. + it('prima dell\'inizio: entrambi, marcati futuri', () => { + expect(idDi('2026-08-17T10:00:00+02:00')) + .toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']); + }); + + it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => { + expect(idDi('2026-09-05T10:00:00+02:00')) + .toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']); + }); + + // Un orario scaduto non deve restare in pagina: farebbe cercare lezioni che non ci sono più. + it('finito il primo periodo, il primo orario sparisce', () => { + expect(idDi('2026-09-25T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']); + expect(idDi('2027-03-01T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']); + }); + + // 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class: + // il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08). + it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => { + expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']); + expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']); + }); + + it('confini al secondo, in ora italiana', () => { + expect(idDi('2026-09-18T23:59:59+02:00')) + .toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']); + expect(idDi('2026-09-19T00:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']); + expect(idDi('2026-09-21T00:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']); + }); + + it('l\'ordine dell\'elenco di partenza non cambia il risultato', () => { + const rovescio = [...calendari].reverse(); + expect(calendariDaMostrare(d('2026-09-05T10:00:00+02:00'), rovescio).map((m) => m.cal.id)) + .toEqual(['settembre-1-18', 'settembre-21-in-poi']); + }); +}); + +describe('giornate fuori griglia', () => { + it('la presentazione e la chiusura si vedono prima di arrivarci', () => { + const oggi = avvisiAttivi(d('2026-08-17T12:00:00+02:00')).map((a) => a.id); + expect(oggi).toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']); + }); + + // Un avviso passato è peggio di nessun avviso: dice che il centro è chiuso un giorno che è + // già trascorso, e chi legge non sa più se valga per quest'anno o per il prossimo. + it('ogni avviso sparisce da solo quando è passato', () => { + expect(avvisiAttivi(d('2026-09-19T23:59:59+02:00')).map((a) => a.id)) + .toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']); + expect(avvisiAttivi(d('2026-09-20T00:00:00+02:00')).map((a) => a.id)) + .toEqual(['chiusura-20-settembre']); + expect(avvisiAttivi(d('2026-09-21T00:00:00+02:00'))).toEqual([]); + }); + + it('ogni avviso ha una scadenza leggibile e un testo', () => { + for (const a of avvisi) { + expect(Number.isNaN(Date.parse(a.to)), `${a.id}: scadenza illeggibile`).toBe(false); + if (a.from !== null) expect(Date.parse(a.to)).toBeGreaterThanOrEqual(Date.parse(a.from)); + expect(a.quando.length).toBeGreaterThan(0); + expect(a.testo.length).toBeGreaterThan(0); + } + }); +}); + +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); + } + }); + }); + } +}); diff --git a/tests/calendario-pdf.test.ts b/tests/calendario-pdf.test.ts new file mode 100644 index 0000000..a4353f2 --- /dev/null +++ b/tests/calendario-pdf.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { avvisi, calendari } from '../src/data/calendari'; +import { generaCalendariPdf, nomeFilePdf } from '../src/lib/calendario-pdf'; + +describe('PDF del calendario', () => { + for (const cal of calendari) { + it(`${cal.id}: produce un PDF valido`, async () => { + const pdf = await generaCalendariPdf([cal]); + // Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse + // chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al + // browser, che mostrerebbe una pagina bianca invece di un errore. + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + expect(pdf.subarray(-8).toString()).toContain('%%EOF'); + expect(pdf.length).toBeGreaterThan(2000); + }); + } + + // La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a + // cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto + // /Type /Pages, che dichiara quante pagine contiene. + it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => { + for (const cal of calendari) { + const pdf = await generaCalendariPdf([cal], avvisi); + const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1')); + expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull(); + expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1); + } + }); + + it('il nome del file distingue i due periodi', () => { + const nomi = calendari.map((c) => nomeFilePdf([c])); + expect(new Set(nomi).size).toBe(nomi.length); + for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/); + // Più orari insieme: un nome che nominasse solo il primo farebbe credere che dentro ci + // sia quello e basta. + expect(nomeFilePdf(calendari)).toBe('insanitylab-calendario-class.pdf'); + }); + + // Un orario per pagina: due griglie sullo stesso foglio si confonderebbero fra loro, e + // spezzarne una a metà toglie proprio ciò che si va a cercare, l'incrocio giorno/ora. + it('con più orari fa una pagina per ciascuno', async () => { + const pdf = await generaCalendariPdf(calendari, avvisi); + const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1')); + expect(conteggio).not.toBeNull(); + expect(Number(conteggio![1])).toBe(calendari.length); + }); +}); 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); + }); +}); 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); + } + }); +});