Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5fac89c1b | |||
| 665c598462 | |||
| 4103376eb1 | |||
| b75e12de86 | |||
| 718ca499c4 | |||
| 4f031cf571 | |||
| 6ba8794770 | |||
| 0ea1cb5e77 | |||
| 7f96b95e04 | |||
| 13d73f90a8 | |||
| 7fd3961469 | |||
| ecab67805b | |||
| cdfbf194c2 | |||
| 76adef3fc7 | |||
| d079019dcf | |||
| 48909e8c8b | |||
| cc6945c3d1 | |||
| f7a9ae47d1 | |||
| 9772b3a3f6 | |||
| 32c8a510bb | |||
| 20f0c2120b | |||
| 6e002d4dc1 | |||
| 3790d6d506 |
@@ -7,3 +7,5 @@ data/*.db*
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
assets-src/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -23,7 +23,12 @@ services:
|
||||
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
|
||||
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
|
||||
# Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex
|
||||
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`)
|
||||
# ⚠️ `insanitylab.tielogic.xyz` tolto dal DNS da Adriano il 03/09/2026: il record non
|
||||
# esiste piu'. Lasciarlo qui non e' inerte — il router chiede un certificato per i
|
||||
# nomi della sua regola, e una sfida ACME su un nome che non risolve fallisce a ogni
|
||||
# tentativo, che e' esattamente cio' che Let's Encrypt limita. Il vecchio dominio ha
|
||||
# smesso di essere raggiungibile: da qui in poi vale solo il redirect di `www`.
|
||||
- traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`)
|
||||
- traefik.http.routers.insanitylab-redir.tls=true
|
||||
- traefik.http.routers.insanitylab-redir.entrypoints=websecure
|
||||
- traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 242 KiB |
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Genera la locandina dell'In-Sanity Hour in PDF vettoriale, con il QR code vero.
|
||||
|
||||
Perché non si parte da un sorgente grafico: l'unico file disponibile è un JPEG da
|
||||
612x751 px, che stampato su A4 darebbe 70 dpi. La locandina però è fatta di tre soli
|
||||
colori piatti, quindi si può ricalcare: ogni forma — testi, calligrafia, bicchieri —
|
||||
diventa un tracciato vettoriale che si stampa nitido a qualsiasi formato, senza dover
|
||||
reinstallare i font originali (che non abbiamo) né riprodurli a occhio.
|
||||
|
||||
Del JPEG resta solo la geometria. Il QR di prova viene coperto e ridisegnato con
|
||||
l'URL vero della pagina evento, anch'esso vettoriale.
|
||||
|
||||
Uso: python3 scripts/locandina-evento.py
|
||||
Serve: pip install --user potracer qrcode cairosvg
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
import numpy as np
|
||||
import potrace
|
||||
import qrcode
|
||||
from PIL import Image
|
||||
|
||||
URL_EVENTO = 'https://insanitylab.it/promo-eventi/eventi'
|
||||
|
||||
RADICE = Path(__file__).resolve().parent.parent
|
||||
SORGENTE = RADICE.parent / 'Scaricati' / 'Locandina.jpeg'
|
||||
USCITA_PDF = RADICE / 'public' / 'locandina-insanity-hour.pdf'
|
||||
USCITA_PNG = RADICE / 'public' / 'img' / 'locandina-insanity-hour.png'
|
||||
USCITA_TITOLO = RADICE / 'public' / 'img' / 'insanity-hour-titolo.png'
|
||||
|
||||
# Riquadro stampato dentro il JPEG, senza il bordo bianco dello screenshot.
|
||||
RIQUADRO = (22, 13, 597, 732)
|
||||
# Blocco "SAVE THE DATE / IN-SANITY / Hour" usato come immagine nella pagina web.
|
||||
TITOLO_WEB = (23, 24, 596, 275)
|
||||
# Quadrato del QR di prova da sostituire, in coordinate del JPEG originale.
|
||||
QR_PROVA = (387, 632, 475, 720)
|
||||
# Bordo superiore della fascia beige: la toppa sul QR non deve sconfinare nel marrone.
|
||||
FASCIA_Y = 627
|
||||
|
||||
FONDO, BEIGE, CREMA = '#4F3126', '#D0B49E', '#F6F1E9'
|
||||
CENTRI = [(79, 49, 38), (208, 180, 158), (246, 241, 233)]
|
||||
UPSCALE = 4 # si traccia sull'immagine ingrandita: bordi piu' morbidi, curve piu' fedeli
|
||||
MODULI_QUIET = 4 # margine muto richiesto dallo standard QR, in moduli
|
||||
|
||||
|
||||
def classifica(immagine: Image.Image) -> np.ndarray:
|
||||
"""Assegna a ogni pixel il piu' vicino dei tre colori piatti della locandina."""
|
||||
grande = immagine.resize((immagine.width * UPSCALE, immagine.height * UPSCALE), Image.LANCZOS)
|
||||
px = np.asarray(grande).astype(np.int16)
|
||||
distanze = np.stack([((px - np.array(c)) ** 2).sum(axis=2) for c in CENTRI])
|
||||
return np.argmin(distanze, axis=0)
|
||||
|
||||
|
||||
def traccia(maschera: np.ndarray) -> str:
|
||||
"""Contorni della maschera come path SVG. potracer prende per foreground i pixel
|
||||
falsi, quindi la maschera va negata."""
|
||||
percorso = potrace.Bitmap(~maschera).trace(turdsize=6, alphamax=1.0, opticurve=True, opttolerance=0.2)
|
||||
d = []
|
||||
for curva in percorso.curves:
|
||||
avvio = curva.start_point
|
||||
d.append(f'M{avvio.x / UPSCALE:.2f},{avvio.y / UPSCALE:.2f}')
|
||||
for seg in curva.segments:
|
||||
fine = seg.end_point
|
||||
if seg.is_corner:
|
||||
d.append(f'L{seg.c.x / UPSCALE:.2f},{seg.c.y / UPSCALE:.2f}L{fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}')
|
||||
else:
|
||||
d.append(
|
||||
f'C{seg.c1.x / UPSCALE:.2f},{seg.c1.y / UPSCALE:.2f}'
|
||||
f' {seg.c2.x / UPSCALE:.2f},{seg.c2.y / UPSCALE:.2f}'
|
||||
f' {fine.x / UPSCALE:.2f},{fine.y / UPSCALE:.2f}'
|
||||
)
|
||||
d.append('Z')
|
||||
return ''.join(d)
|
||||
|
||||
|
||||
def qr_svg(x0: float, y0: float, x1: float, y1: float) -> str:
|
||||
"""QR dell'URL evento, disegnato a rettangoli dentro il riquadro dato.
|
||||
|
||||
Il QR di prova sfiorava il bordo marrone: qui il modulo viene calcolato includendo
|
||||
il margine muto, cosi' il codice resta leggibile anche stampato in piccolo.
|
||||
"""
|
||||
codice = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=1, border=0)
|
||||
codice.add_data(URL_EVENTO)
|
||||
codice.make(fit=True)
|
||||
matrice = codice.get_matrix()
|
||||
n = len(matrice)
|
||||
|
||||
lato = min(x1 - x0, y1 - y0)
|
||||
modulo = lato / (n + 2 * MODULI_QUIET)
|
||||
off_x = x0 + (x1 - x0 - lato) / 2 + MODULI_QUIET * modulo
|
||||
off_y = y0 + (y1 - y0 - lato) / 2 + MODULI_QUIET * modulo
|
||||
|
||||
quadrati = [
|
||||
f'<rect x="{off_x + c * modulo:.3f}" y="{off_y + r * modulo:.3f}" '
|
||||
f'width="{modulo:.3f}" height="{modulo:.3f}"/>'
|
||||
for r, riga in enumerate(matrice)
|
||||
for c, pieno in enumerate(riga)
|
||||
if pieno
|
||||
]
|
||||
return f'<g fill="{FONDO}" shape-rendering="crispEdges">{"".join(quadrati)}</g>', n
|
||||
|
||||
|
||||
def main() -> None:
|
||||
originale = Image.open(SORGENTE).convert('RGB')
|
||||
locandina = originale.crop(RIQUADRO)
|
||||
larghezza, altezza = locandina.size
|
||||
etichette = classifica(locandina)
|
||||
|
||||
dx, dy = RIQUADRO[0], RIQUADRO[1]
|
||||
qx0, qy0, qx1, qy1 = QR_PROVA[0] - dx, QR_PROVA[1] - dy, QR_PROVA[2] - dx, QR_PROVA[3] - dy
|
||||
qr, moduli = qr_svg(qx0, qy0, qx1, qy1)
|
||||
toppa_y = max(qy0 - 6, FASCIA_Y - dy + 1)
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {larghezza} {altezza}" width="{larghezza}" height="{altezza}">
|
||||
<rect width="{larghezza}" height="{altezza}" fill="{FONDO}"/>
|
||||
<path fill="{BEIGE}" d="{traccia(etichette == 1)}"/>
|
||||
<path fill="{CREMA}" d="{traccia(etichette == 2)}"/>
|
||||
<rect x="{qx0 - 6}" y="{toppa_y}" width="{qx1 - qx0 + 12}" height="{qy1 + 6 - toppa_y}" fill="{BEIGE}"/>
|
||||
{qr}
|
||||
</svg>'''
|
||||
|
||||
USCITA_PDF.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Proporzioni native (575x719). cairosvg legge i px a 96 dpi: per una base di 210 mm
|
||||
# (la larghezza di un A4) la pagina va chiesta in px, non in punti.
|
||||
px_per_mm = 96 / 25.4
|
||||
scala = 210 * px_per_mm / larghezza
|
||||
cairosvg.svg2pdf(bytestring=svg.encode(), write_to=str(USCITA_PDF),
|
||||
output_width=larghezza * scala, output_height=altezza * scala)
|
||||
cairosvg.svg2png(bytestring=svg.encode(), write_to=str(USCITA_PNG),
|
||||
output_width=larghezza * 3, output_height=altezza * 3)
|
||||
originale.crop(TITOLO_WEB).save(USCITA_TITOLO)
|
||||
|
||||
print(f'locandina {larghezza}x{altezza} px -> {USCITA_PDF.relative_to(RADICE)} '
|
||||
f'({210:.0f}x{210 * altezza / larghezza:.0f} mm)')
|
||||
print(f'anteprima {larghezza * 3}x{altezza * 3} -> {USCITA_PNG.relative_to(RADICE)}')
|
||||
print(f'QR {moduli}x{moduli} moduli -> {URL_EVENTO}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 480 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 554 KiB |
@@ -173,7 +173,23 @@ const disciplinePresenti = (cal: Calendario) =>
|
||||
|
||||
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
|
||||
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
|
||||
.cal-tab { border-collapse: collapse; width: 100%; min-width: 720px; }
|
||||
/* `table-layout: fixed` e' quello che rende le sette colonne dei giorni ESATTAMENTE
|
||||
uguali: in automatico il browser le dimensiona sul contenuto, cosi' il sabato con la
|
||||
nota «solo il 12 settembre» diventava largo il doppio e i giorni vuoti si stringevano.
|
||||
Con il layout fisso basta dare una larghezza alla colonna delle ore: tutto il resto
|
||||
viene diviso in parti uguali fra le sette rimanenti. E' lo stesso criterio del PDF,
|
||||
dove le colonne sono `(larghezzaUtile - larghezzaOra) / 7` da sempre.
|
||||
|
||||
⚠️ Il `min-width` e' passato da 720 a 780px INSIEME al layout fisso, e i due sono
|
||||
legati: a colonne uguali la piu' stretta deve contenere la parola piu' lunga, mentre
|
||||
in layout automatico il browser allargava da solo le colonne piene e stringeva quelle
|
||||
vuote. Il conto, misurato sul Montserrat 400 che il sito serve davvero:
|
||||
REFORMER a .7rem con letter-spacing .1em = 75,4 px
|
||||
colonna a 720px = (720 - 64) / 7 = 93,7 px, meno 20 di padding e 1 di bordo = 72,7
|
||||
cioe' 2,7 px in meno del necessario: la parola sarebbe uscita dalla cella sul
|
||||
telefono, dove il minimo e' quello che si vede. A 780px la colonna da' 81,3 px di
|
||||
spazio utile, ~6 di margine. Chi tocca padding, corpo o spaziatura rifaccia il conto. */
|
||||
.cal-tab { border-collapse: collapse; width: 100%; min-width: 780px; table-layout: fixed; }
|
||||
.cal-tab th, .cal-tab td { border: 1px solid #e4e0d9; padding: 9px 10px; text-align: left; }
|
||||
.cal-tab thead th {
|
||||
background: var(--c-bg-alt);
|
||||
@@ -184,6 +200,9 @@ const disciplinePresenti = (cal: Calendario) =>
|
||||
display: block; font-size: .62rem; letter-spacing: .06em; text-transform: none;
|
||||
color: var(--c-text-light); font-weight: 400;
|
||||
}
|
||||
/* L'unica larghezza dichiarata: la colonna delle ore. Resta piu' stretta dei giorni,
|
||||
come nel PDF — ci sta dentro «12.15» e nient'altro. */
|
||||
.cal-tab__ora-h, .cal-tab__ora { width: 64px; }
|
||||
.cal-tab__ora-h { color: var(--c-accent-dark) !important; }
|
||||
.cal-tab__ora {
|
||||
background: var(--c-bg);
|
||||
|
||||
@@ -47,13 +47,20 @@ export const calendari: Calendario[] = [
|
||||
to: '2026-09-18T23:59:59+02:00',
|
||||
chiusi: ['DOM'],
|
||||
noteGiorno: { SAB: 'solo il 12 settembre' },
|
||||
// ⚠️ Il VENERDI' non ha nessuna class in questo periodo, ed e' voluto: le tre che
|
||||
// c'erano (MOBILITY 8.30, REFORMER 9.15 e 10.00) sono state tolte su richiesta del
|
||||
// cliente l'01/09/2026, ed erano tutte di mattina. Non e' una trascrizione monca:
|
||||
// confrontando questa griglia con quella dal 21 settembre — dove il venerdi' c'e' —
|
||||
// la differenza salta all'occhio e sembra un errore. Non lo e'.
|
||||
// La colonna resta VUOTA e non barrata: togliere le class non vuol dire che la sala
|
||||
// sia chiusa, e nessuno ce l'ha detto (scelta di Adriano, 01/09).
|
||||
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: '8.30', celle: { MER: 'REFORMER' } },
|
||||
{ ora: '9.15', celle: { SAB: 'MOBILITY' } },
|
||||
{ ora: '10.00', celle: { 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' } },
|
||||
|
||||
@@ -165,7 +165,7 @@ export const contentSeed: SeedEntry[] = [
|
||||
// --- About: meta, hero ---
|
||||
T('about.meta.title', 'Insanitylab', []),
|
||||
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
|
||||
T('about.hero.title', 'Insanitylab', []),
|
||||
T('about.hero.title', 'About us', []),
|
||||
|
||||
// --- About: mission ---
|
||||
T('about.mission.title', 'Mission'),
|
||||
@@ -318,7 +318,8 @@ export const contentSeed: SeedEntry[] = [
|
||||
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.title', 'Inaugurazione'),
|
||||
T('eventi.inaugurazione.date', '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', []),
|
||||
|
||||
@@ -1,36 +1,288 @@
|
||||
---
|
||||
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, '')}`;
|
||||
---
|
||||
<Base title={t('eventi.meta.title')} description={t('eventi.meta.description')}>
|
||||
<PageHero title={t('eventi.hero.title')} />
|
||||
// La landing dell'inaugurazione del 19 settembre. Il blocco del titolo è il ritaglio
|
||||
// della locandina vera (font e calligrafia originali, non riprodotti): il resto della
|
||||
// pagina usa la tipografia del sito e i colori campionati dalla locandina stessa.
|
||||
// L'URL di questa pagina è quello stampato nel QR code, quindi non va cambiato.
|
||||
const WHATSAPP = '351 753 5977';
|
||||
const waLink = `https://wa.me/39${WHATSAPP.replace(/\D/g, '')}`;
|
||||
|
||||
<section class="section">
|
||||
type Slot = { ora: string; nome?: string };
|
||||
type Gruppo = { label: string; slots: Slot[] };
|
||||
|
||||
const mattino: Gruppo[] = [
|
||||
{ label: "Corsi Olistici — 20' a slot", slots: [
|
||||
{ ora: '10:00–10:20', nome: 'Mobility' },
|
||||
{ ora: '10:30–10:50', nome: 'Yoga' },
|
||||
{ ora: '11:00–11:20', nome: 'Pilates Mat' },
|
||||
{ ora: '11:30–11:50', nome: 'Reformer' },
|
||||
{ ora: '12:00–12:20', nome: 'Reformer' },
|
||||
{ ora: '12:30–12:50', nome: 'Reformer' },
|
||||
] },
|
||||
{ label: "Personal Training — 30' a slot", slots: ['10:30', '11:00', '11:30', '12:00', '12:30'].map((ora) => ({ ora })) },
|
||||
{ label: "Check-up e Consulenze — 30' a slot", slots: ['10:30', '11:00', '11:30', '12:00', '12:30'].map((ora) => ({ ora })) },
|
||||
];
|
||||
|
||||
const pomeriggio: Gruppo[] = [
|
||||
{ label: "Corsi Olistici — 20' a slot", slots: [
|
||||
{ ora: '16:00–16:20', nome: 'Reformer' },
|
||||
{ ora: '16:30–16:50', nome: 'Reformer' },
|
||||
{ ora: '17:00–17:20', nome: 'Pilates' },
|
||||
{ ora: '17:30–17:50', nome: 'Mobility' },
|
||||
] },
|
||||
{ label: "Personal Training — 30' a slot", slots: ['16:00', '16:30', '17:00', '17:30'].map((ora) => ({ ora })) },
|
||||
{ label: "Check-up e Consulenze — 30' a slot", slots: ['16:00', '16:30', '17:00', '17:30'].map((ora) => ({ ora })) },
|
||||
];
|
||||
---
|
||||
<Base
|
||||
title="In-Sanity Hour — 19 settembre 2026"
|
||||
description="L'inaugurazione di InsanityLab: venerdì 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti."
|
||||
>
|
||||
<section class="ev-hero">
|
||||
<div class="ev-hero__inner" data-reveal>
|
||||
<img
|
||||
class="ev-hero__titolo"
|
||||
src="/img/insanity-hour-titolo.png"
|
||||
width="580" height="251"
|
||||
alt="Save the date — In-Sanity Hour"
|
||||
/>
|
||||
<p class="ev-hero__giorno">19</p>
|
||||
<p class="ev-hero__mese">Settembre</p>
|
||||
<p class="ev-hero__inaug">Inaugurazione</p>
|
||||
<p class="ev-hero__claim">Un giorno solo per vivere il Lab da vicino.</p>
|
||||
<p class="ev-hero__gusto">DJ set · Aperitivo</p>
|
||||
<p class="ev-hero__luogo">Via Pietro di Mattiolo, 5 — Bologna</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section ev-intro">
|
||||
<div class="container" data-reveal>
|
||||
<p class="ev-intro__testo">
|
||||
Una giornata aperta a tutti — clienti e non — per scoprire la nuova struttura e conoscere
|
||||
il nostro staff. Vivi l'esperienza In-Sanity Lab da vicino: mini class, allenamenti,
|
||||
consulenze e check up avanzato.
|
||||
</p>
|
||||
<p class="ev-intro__gratis">Solo per oggi, tutto gratuito</p>
|
||||
<p class="ev-intro__testo">
|
||||
L'ingresso e l'aperitivo serale sono liberi, porta chi vuoi. La prenotazione serve solo
|
||||
per assicurarti il posto nei singoli slot di prova.
|
||||
</p>
|
||||
<a class="btn" href={waLink} target="_blank" rel="noopener">Prenotati su WhatsApp</a>
|
||||
<p class="ev-nota">{WHATSAPP} — rispondiamo entro la giornata</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section ev-programma">
|
||||
<div class="container">
|
||||
<article class="evento" data-reveal>
|
||||
<T tag="eventi.inaugurazione.title" as="h2" />
|
||||
<T tag="eventi.inaugurazione.body" as="p" />
|
||||
<a class="btn" href={waLink} target="_blank" rel="noopener">{t('eventi.cta.label')}</a>
|
||||
<p class="eyebrow" data-reveal>Programma della giornata</p>
|
||||
<h2 data-reveal>Come si svolge<br />l'In-Sanity Hour</h2>
|
||||
|
||||
<article class="ev-blocco" data-reveal>
|
||||
<p class="ev-blocco__ora">10:00</p>
|
||||
<h3>Apertura</h3>
|
||||
<p class="ev-blocco__desc">
|
||||
Apertura ufficiale del Lab. Visita libera degli spazi, promozioni dedicate attive
|
||||
per tutta la giornata.
|
||||
</p>
|
||||
</article>
|
||||
<p class="evento__torna"><a href="/promo-eventi">{t('eventi.torna.label')}</a></p>
|
||||
|
||||
<article class="ev-blocco" data-reveal>
|
||||
<p class="ev-blocco__ora">10:00 – 13:00</p>
|
||||
<h3>Prova i nostri servizi</h3>
|
||||
{mattino.map((gruppo) => (
|
||||
<div class="ev-gruppo">
|
||||
<p class="ev-gruppo__label">{gruppo.label}</p>
|
||||
<ul class="ev-slots">
|
||||
{gruppo.slots.map((slot) => (
|
||||
<li class:list={['ev-slot', { 'ev-slot--corso': slot.nome }]}>
|
||||
<span class="ev-slot__ora">{slot.ora}</span>
|
||||
{slot.nome && <span class="ev-slot__nome">{slot.nome}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<a class="btn btn--chiaro" href={waLink} target="_blank" rel="noopener">Prenota uno slot</a>
|
||||
<p class="ev-nota">{WHATSAPP} — scrivici per riservare uno slot del mattino</p>
|
||||
</article>
|
||||
|
||||
<article class="ev-blocco ev-blocco--pausa" data-reveal>
|
||||
<p class="ev-blocco__ora">13:00 – 16:00</p>
|
||||
<h3>Pausa</h3>
|
||||
</article>
|
||||
|
||||
<article class="ev-blocco" data-reveal>
|
||||
<p class="ev-blocco__ora">16:00 – 18:00</p>
|
||||
<h3>Prova i nostri servizi</h3>
|
||||
{pomeriggio.map((gruppo) => (
|
||||
<div class="ev-gruppo">
|
||||
<p class="ev-gruppo__label">{gruppo.label}</p>
|
||||
<ul class="ev-slots">
|
||||
{gruppo.slots.map((slot) => (
|
||||
<li class:list={['ev-slot', { 'ev-slot--corso': slot.nome }]}>
|
||||
<span class="ev-slot__ora">{slot.ora}</span>
|
||||
{slot.nome && <span class="ev-slot__nome">{slot.nome}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<a class="btn btn--chiaro" href={waLink} target="_blank" rel="noopener">Prenota uno slot</a>
|
||||
<p class="ev-nota">{WHATSAPP} — scrivici per riservare uno slot del pomeriggio</p>
|
||||
</article>
|
||||
|
||||
<article class="ev-blocco ev-blocco--festa" data-reveal>
|
||||
<p class="ev-blocco__ora">18:00 – 22:00</p>
|
||||
<h3>Si festeggia!</h3>
|
||||
<p class="ev-blocco__desc">
|
||||
DJ set e aperitivo per tutti. Brindisi e la nuova sede finalmente aperta a tutti.
|
||||
Tra un drink e l'altro, la serata avrà anche i suoi momenti a sorpresa.
|
||||
Promozioni esclusive valide solo durante l'evento.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ev-chiusura">
|
||||
<div class="container" data-reveal>
|
||||
<blockquote class="ev-citazione">
|
||||
«Qui non ti alleni per somigliare a nessuno,<br />ti muovi per ricordare chi sei.»
|
||||
</blockquote>
|
||||
<p class="ev-chiusura__luogo">Via Pietro di Mattiolo, 5 — 40139, Bologna</p>
|
||||
<a class="ev-locandina" href="/locandina-insanity-hour.pdf" download>Scarica la locandina</a>
|
||||
<p class="ev-torna"><a href="/promo-eventi">← Torna a promo ed eventi</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.evento { max-width: 760px; margin: 0 auto; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.evento :global(h2) { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
|
||||
.evento :global(p) { margin: 0; }
|
||||
.evento__torna { text-align: center; margin-top: 46px; }
|
||||
.evento__torna a { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-heading); text-decoration: none; }
|
||||
.evento__torna a:hover { color: var(--c-accent-dark); }
|
||||
/* Colori campionati dalla locandina: il marrone e il beige sono quelli veri, non quelli
|
||||
del sito, così la pagina e il volantino stampato si riconoscono come la stessa cosa. */
|
||||
.ev-hero, .ev-intro, .ev-programma, .ev-chiusura {
|
||||
--ev-brown: #4f3126;
|
||||
--ev-brown-deep: #3d251c;
|
||||
--ev-beige: #d0b49e;
|
||||
--ev-cream: #f6f1e9;
|
||||
--ev-line: rgba(208, 180, 158, .35);
|
||||
}
|
||||
|
||||
/* --- Hero: il ritaglio della locandina su fondo marrone identico, così l'immagine
|
||||
non ha bordi visibili e sembra composta con il resto della pagina. --- */
|
||||
.ev-hero {
|
||||
background: radial-gradient(circle at 50% -10%, #5c3a2d 0%, var(--ev-brown) 55%, var(--ev-brown-deep) 100%);
|
||||
color: var(--ev-cream);
|
||||
padding: 64px 24px 72px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.ev-hero::before {
|
||||
content: ''; position: absolute; inset: 14px;
|
||||
border: 1px solid var(--ev-line); pointer-events: none;
|
||||
}
|
||||
.ev-hero__inner { max-width: 640px; margin: 0 auto; position: relative; }
|
||||
.ev-hero__titolo { width: min(100%, 540px); margin: 0 auto; mix-blend-mode: lighten; }
|
||||
.ev-hero__giorno {
|
||||
font-family: var(--font-heading); font-size: clamp(3.4rem, 11vw, 4.6rem);
|
||||
font-weight: 300; line-height: 1; letter-spacing: .04em; margin: 26px 0 0;
|
||||
}
|
||||
.ev-hero__mese {
|
||||
font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
|
||||
letter-spacing: .35em; text-transform: uppercase; color: var(--ev-beige); margin: 10px 0 0;
|
||||
}
|
||||
.ev-hero__inaug {
|
||||
display: inline-block; margin: 20px 0 0; padding: 7px 16px;
|
||||
border: 1px solid var(--ev-line);
|
||||
font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
||||
letter-spacing: .24em; text-transform: uppercase; color: var(--ev-cream);
|
||||
}
|
||||
.ev-hero__claim { margin: 26px 0 0; font-size: 1.05rem; line-height: 1.7; color: rgba(246, 241, 233, .9); }
|
||||
.ev-hero__gusto {
|
||||
margin: 18px 0 0; font-family: var(--font-heading); font-size: .7rem; font-weight: 600;
|
||||
letter-spacing: .28em; text-transform: uppercase; color: var(--ev-beige);
|
||||
}
|
||||
.ev-hero__luogo {
|
||||
margin: 26px 0 0; font-family: var(--font-heading); font-size: .68rem;
|
||||
letter-spacing: .18em; text-transform: uppercase; color: rgba(246, 241, 233, .6);
|
||||
}
|
||||
.ev-locandina {
|
||||
display: inline-block; margin-top: 34px; padding: 12px 22px;
|
||||
border: 1px solid var(--ev-beige); color: var(--ev-beige); text-decoration: none;
|
||||
font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
||||
letter-spacing: .2em; text-transform: uppercase; transition: background .2s, color .2s;
|
||||
}
|
||||
.ev-locandina:hover { background: var(--ev-beige); color: var(--ev-brown); }
|
||||
|
||||
/* --- Intro --- */
|
||||
.ev-intro .container { max-width: 720px; text-align: center; }
|
||||
.ev-intro__testo { margin: 0 0 18px; }
|
||||
.ev-intro__gratis {
|
||||
font-family: var(--font-heading); font-size: clamp(1.15rem, 2.6vw, 1.5rem);
|
||||
font-weight: 600; letter-spacing: .06em; color: var(--ev-brown); margin: 26px 0;
|
||||
}
|
||||
.ev-nota {
|
||||
margin: 14px 0 0; font-size: .84rem; color: var(--c-text-light);
|
||||
}
|
||||
|
||||
/* --- Programma --- */
|
||||
.ev-programma { background: var(--c-bg-alt); }
|
||||
.ev-programma .container { max-width: 800px; }
|
||||
.ev-programma .eyebrow, .ev-programma h2 { text-align: center; }
|
||||
.ev-programma h2 { margin-bottom: 46px; }
|
||||
.ev-blocco {
|
||||
background: #fff; border-left: 3px solid var(--ev-beige);
|
||||
padding: 30px 30px 34px; margin-bottom: 20px; text-align: center;
|
||||
}
|
||||
.ev-blocco__ora {
|
||||
font-family: var(--font-heading); font-size: .78rem; font-weight: 600;
|
||||
letter-spacing: .2em; color: var(--ev-brown); margin: 0 0 8px;
|
||||
}
|
||||
.ev-blocco h3 { text-transform: uppercase; letter-spacing: .08em; margin: 0 0 12px; }
|
||||
.ev-blocco__desc { margin: 0; }
|
||||
.ev-blocco--pausa { border-left-color: var(--c-text-light); opacity: .75; }
|
||||
.ev-blocco--festa { border-left-color: var(--ev-brown); }
|
||||
|
||||
.ev-gruppo { margin-bottom: 24px; }
|
||||
.ev-gruppo__label {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
|
||||
letter-spacing: .14em; text-transform: uppercase; color: var(--c-text-light);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.ev-slots {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
|
||||
}
|
||||
.ev-slot {
|
||||
border: 1px solid var(--ev-line); padding: 8px 14px;
|
||||
font-family: var(--font-heading); font-size: .78rem; color: var(--ev-brown);
|
||||
}
|
||||
.ev-slot--corso { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ev-slot__nome { font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-text-light); }
|
||||
.btn--chiaro { margin-top: 10px; }
|
||||
|
||||
/* --- Chiusura --- */
|
||||
.ev-chiusura {
|
||||
background: var(--ev-brown); color: var(--ev-cream);
|
||||
padding: 64px 24px; text-align: center;
|
||||
}
|
||||
.ev-citazione {
|
||||
margin: 0; font-size: clamp(1.35rem, 3.4vw, 1.9rem); line-height: 1.5;
|
||||
font-style: italic; color: var(--ev-cream);
|
||||
}
|
||||
.ev-chiusura__luogo {
|
||||
margin: 30px 0 0; font-family: var(--font-heading); font-size: .68rem;
|
||||
letter-spacing: .18em; text-transform: uppercase; color: var(--ev-beige);
|
||||
}
|
||||
.ev-torna { margin: 34px 0 0; }
|
||||
.ev-torna a {
|
||||
font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: rgba(246, 241, 233, .7); text-decoration: none;
|
||||
}
|
||||
.ev-torna a:hover { color: var(--ev-beige); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ev-hero { padding: 44px 18px 54px; }
|
||||
.ev-blocco { padding: 24px 18px 28px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -130,7 +130,7 @@ const tones = ['tan', 'taupe', 'dark'];
|
||||
.prog { display: grid; grid-template-columns: 2.2fr 1fr; gap: 60px; }
|
||||
.prog :global(.prog__desc) { white-space: pre-line; }
|
||||
.prog__features { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 30px; }
|
||||
.prog__extra { margin-bottom: 10px; }
|
||||
.prog__extra { padding-top: 30px; margin-bottom: 10px; }
|
||||
.prog__extra h2 { letter-spacing: .1em; margin-bottom: .5em; }
|
||||
.prog__features li::marker { color: var(--c-accent); }
|
||||
.prog__img { width: 100%; height: auto; margin-bottom: 30px; }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// L'indirizzo della pagina evento e' stampato dentro il QR code delle locandine affisse
|
||||
// in giro per Bologna: una volta in strada non si corregge piu'. Questi test tengono
|
||||
// insieme le tre cose che devono restare d'accordo — la rotta servita dal sito, l'URL
|
||||
// cucito nel QR e il file che la pagina offre in download.
|
||||
const ROTTA = 'src/pages/promo-eventi/eventi.astro';
|
||||
const URL_STAMPATO = 'https://insanitylab.it/promo-eventi/eventi';
|
||||
|
||||
describe('locandina In-Sanity Hour', () => {
|
||||
it('la rotta che il QR indica esiste', () => {
|
||||
expect(existsSync(ROTTA)).toBe(true);
|
||||
});
|
||||
|
||||
it('il generatore della locandina punta a quella rotta', () => {
|
||||
const script = readFileSync('scripts/locandina-evento.py', 'utf-8');
|
||||
expect(script).toContain(`URL_EVENTO = '${URL_STAMPATO}'`);
|
||||
// La rotta del file deve corrispondere alla coda dell'URL, altrimenti il QR
|
||||
// porterebbe altrove: /promo-eventi/eventi <- src/pages/promo-eventi/eventi.astro
|
||||
const daFile = '/' + ROTTA.replace('src/pages/', '').replace('.astro', '');
|
||||
expect(URL_STAMPATO.endsWith(daFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('la locandina che la pagina offre in download esiste', () => {
|
||||
const pagina = readFileSync(ROTTA, 'utf-8');
|
||||
const link = pagina.match(/href="(\/locandina-[^"]+\.pdf)"/);
|
||||
expect(link).not.toBeNull();
|
||||
expect(existsSync(`public${link![1]}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -183,10 +183,11 @@ describe('promo ed eventi', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
// I tag `eventi.*` restano nel seed — in produzione le righe esistono già — ma dal
|
||||
// commit della landing dell'In-Sanity Hour non alimentano piu' nessuna pagina: la data
|
||||
// vive nel suo tag, non nel titolo, da quando i due sono stati separati.
|
||||
it('la data dell\'inaugurazione sta nel tag della data', () => {
|
||||
expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.date')!.value).toContain('19 settembre 2026');
|
||||
});
|
||||
|
||||
// La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti.
|
||||
|
||||