Compare commits

..

7 Commits

Author SHA1 Message Date
Adriano 7a30bc08b9 Rende la pagina Promo ed eventi modificabile dal pannello contenuti
Tutti i testi della landing (hero, launch, card con prezzi, nota validità e FAQ)
passano ora dal sistema di content-tag `promo.*`, editabili inline e da pannello.
Il numero WhatsApp è centralizzato nel tag globale `global.whatsapp`: le risposte
FAQ usano il segnaposto {{whatsapp}}, sostituito a runtime, così si aggiorna in un
punto solo. Struttura delle 4 card e link di acquisto bsport restano nel codice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:51:10 +02:00
Adriano bcc965655d Aggiunge integrazione Google Tag Manager attivabile via env
GTM viene caricato nel layout Base solo se la variabile GTM_ID è valorizzata.
L'ID è letto a runtime da process.env (non inlinato a build-time), così si
attiva/disattiva senza ricompilare. Con GTM_ID vuoto il sito non carica alcuno
script Google.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:08:58 +02:00
Adriano 8d593dce90 editor inline: combo mostrano il valore reale stimato dall'aspetto
Se un blocco non ha uno stile esplicito, le combo pre-selezionano il preset più
vicino all'aspetto effettivo (getComputedStyle): size dal rapporto col genitore,
color per match RGB, weight/style/align diretti. Marcato "(attuale)" con bordo
tratteggiato. Al salvataggio le stime non modificate non vengono fissate, così il
tema resta intatto; solo le scelte manuali vengono scritte. Gli styles ora sono
inviati sempre per permettere l'azzeramento a "default (tema)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:39:46 +02:00
Adriano 47a93049bb editor inline: restyle e combo con valore impostato pre-selezionato
- GET /api/admin/content/[tag] restituisce anche gli styles correnti del blocco
- Modal ridisegnato (header con chip tag, input arrotondati, griglia combo)
- Combo etichettate in italiano (Dimensione/Colore/Peso/Stile/Allineamento) con
  valori leggibili; pre-selezione del valore già impostato ed evidenziazione

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:18:08 +02:00
Adriano 5ff47711cd menu: riordina le voci (Training, Servizi, About us, Blog & Edugo, Promo, Contatti)
I label del menu vengono da global.nav.N.label (per posizione): riordinati anche
i blocchi contenuto in produzione per allinearli al nuovo ordine degli href.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:54:55 +02:00
Adriano f48518d8fd footer e contatti: social aggiornati e nome sulla mappa
- Footer: Instagram -> in_sanity_lab, LinkedIn -> in-sanity-lab-srl, rimosso Facebook
- Contatti: mapQuery con nome "IN-SANITY LAB" così la mappa mostra l'etichetta

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:48:58 +02:00
Adriano 30b509436e contenuti: aggiunge size xl/xxl e allineamento (con giustificato)
- style-presets: size += xl, xxl; align += justify
- global.css: .cs-size-xl (1.5em), .cs-size-xxl (2em), .cs-align-justify
- content-seed: 'align' nei default styleOptions dei tag testo/html, così il
  picker orientamento (sx/centrato/dx/giustificato) compare nell'editor
- Base.astro: sincronizza la copia hardcoded degli stili nell'editor inline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:48:58 +02:00
8 changed files with 305 additions and 155 deletions
+4
View File
@@ -11,3 +11,7 @@ CONTACT_FROM=postmaster@insanitylab.it
CONTACT_TO=info@insanitylab.it CONTACT_TO=info@insanitylab.it
DB_PATH=data/insanitylab.db DB_PATH=data/insanitylab.db
UPLOADS_DIR=uploads UPLOADS_DIR=uploads
# Google Tag Manager: ID container preso da tagmanager.google.com (accanto al nome container).
# Formato GTM-XXXXXXX. Lascia vuoto per non caricare GTM (es. in locale/staging).
GTM_ID=GTM-XXXXXXX
+76 -2
View File
@@ -11,9 +11,9 @@ import { performanceClassPricing } from './pricing';
export type ContentType = 'text' | 'html' | 'image'; export type ContentType = 'text' | 'html' | 'image';
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] } export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style']): SeedEntry => const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style', 'align']): SeedEntry =>
({ tag, type: 'text', value, styleOptions }); ({ tag, type: 'text', value, styleOptions });
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): SeedEntry => const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'align']): SeedEntry =>
({ tag, type: 'html', value, styleOptions }); ({ tag, type: 'html', value, styleOptions });
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' }); const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
@@ -24,6 +24,7 @@ export const contentSeed: SeedEntry[] = [
T('global.contact.address', site.address, []), T('global.contact.address', site.address, []),
T('global.contact.city-line', site.cityLine, []), T('global.contact.city-line', site.cityLine, []),
T('global.contact.map-query', site.mapQuery, []), T('global.contact.map-query', site.mapQuery, []),
T('global.whatsapp', '351 753 5977', []),
T('global.hours.1', site.hours[0], []), T('global.hours.1', site.hours[0], []),
T('global.hours.2', site.hours[1], []), T('global.hours.2', site.hours[1], []),
T('global.nav.1.label', site.navigation[0].label, []), T('global.nav.1.label', site.navigation[0].label, []),
@@ -299,6 +300,79 @@ export const contentSeed: SeedEntry[] = [
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'), T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
T('notfound.cta', 'Torna alla home', []), T('notfound.cta', 'Torna alla home', []),
// --- Promo ed eventi (promo-eventi.astro) ---
// Hero
T('promo.hero.badge', 'insanity.lab · bologna savena', []),
T('promo.hero.title1', 'Allenati con metodo.'),
T('promo.hero.accent', 'Scegli come'),
T('promo.hero.title2', 'vuoi farlo.'),
H('promo.hero.desc', 'Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.'),
T('promo.hero.cta', 'LE NOSTRE PROPOSTE →', []),
// Launch
T('promo.launch.badge', 'solo per apertura nuova sede Settembre 2026', []),
T('promo.launch.title', 'Il nostro metodo nasce per durare.'),
T('promo.launch.accent', "Per l'apertura, un'eccezione. Solo per poco."),
T('promo.launch.desc', "In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte."),
// Perché 5
T('promo.why5.text', 'Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.'),
// Label bottone acquisto (condivisa dalle 4 card)
T('promo.buy-label', 'ACQUISTA →', []),
// Card 1 — One to One
T('promo.card.1.eyebrow', 'Personal training', []),
T('promo.card.1.title', 'One to One'),
T('promo.card.1.price', '163 €', []),
T('promo.card.1.price-old', '300 €', []),
T('promo.card.1.unit', '5 incontri', []),
T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
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)
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.price', '110 €', []),
T('promo.card.2.price-old', '220 €', []),
T('promo.card.2.unit', '5 incontri', []),
T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'),
T('promo.card.2.feature.2', 'Check-up con profilazione fisica, il tuo punto di partenza'),
T('promo.card.2.feature.3', 'PT dedicato, presente a ogni sessione'),
T('promo.card.2.feature.4', 'In-sanity plan, il tuo programma personalizzato'),
// Card 3 — Reformer
T('promo.card.3.eyebrow', 'Pilates class', []),
T('promo.card.3.title', 'Reformer'),
T('promo.card.3.price', '100 €', []),
T('promo.card.3.price-old', '140 €', []),
T('promo.card.3.unit', '5 incontri', []),
T('promo.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'),
T('promo.card.3.feature.2', 'Lavoro a basso impatto sulle articolazioni'),
T('promo.card.3.feature.3', 'Percorso breve, non lezioni isolate'),
// Card 4 — Holistic class
T('promo.card.4.eyebrow', 'Holistic class', []),
T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'),
T('promo.card.4.price', '50 €', []),
T('promo.card.4.price-old', '100 €', []),
T('promo.card.4.unit', '5 incontri', []),
T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'),
T('promo.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'),
T('promo.card.4.feature.3', 'Adatto a tutti i livelli'),
// Nota validità
T('promo.validity-note', 'Disponibile solo per l\'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []),
// FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp
T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'),
T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'),
H('promo.faq.1.a', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi ci scrivi su <span class="hl">WhatsApp ({{whatsapp}})</span> per prenotare le singole sessioni in base agli orari disponibili. 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', 'Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi vai nella schermata Home, clicca il servizio che hai acquistato e seleziona autonomamente la class nel giorno e nell\'orario che desideri. In caso di problemi contattaci su <span class="hl">WhatsApp ({{whatsapp}})</span>! 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 (<span class="hl">One to One</span>, <span class="hl">Small Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). 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 <span class="hl">WhatsApp ({{whatsapp}})</span> per organizzare le sessioni.'),
T('promo.faq.5.q', 'Il pagamento è sicuro?'),
H('promo.faq.5.a', 'Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, 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.'),
T('promo.faq.6.q', 'Posso chiedere un rimborso?'),
H('promo.faq.6.a', 'Per richieste di rimborso o problemi con l\'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.'),
]; ];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e])); export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+5 -6
View File
@@ -5,20 +5,19 @@ export const site = {
// Due ingressi dello stesso stabile. address = testo mostrato; mapQuery = punto geolocalizzabile per Google Maps. // Due ingressi dello stesso stabile. address = testo mostrato; mapQuery = punto geolocalizzabile per Google Maps.
address: "Via Leandro Alberti 76 e Via De Mattiolo 5", address: "Via Leandro Alberti 76 e Via De Mattiolo 5",
cityLine: "40139 Bologna", cityLine: "40139 Bologna",
mapQuery: "Via Leandro Alberti 76, 40139 Bologna", mapQuery: "IN-SANITY LAB, Via Leandro Alberti 76, 40139 Bologna",
hours: ["Lun Ven 6.00 21.00", "Sab 8.00 19.00 · Dom chiuso"], hours: ["Lun Ven 6.00 21.00", "Sab 8.00 19.00 · Dom chiuso"],
socials: [ socials: [
{ label: "Instagram", url: "https://www.instagram.com/insanitylab" }, { label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
{ label: "Facebook", url: "https://www.facebook.com/insanitylab" }, { label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
{ label: "LinkedIn", url: "https://www.linkedin.com/company/insanitylab" },
], ],
navigation: [ navigation: [
{ label: "About us", href: "/about" },
{ label: "Training", href: "/training" }, { label: "Training", href: "/training" },
{ label: "Servizi", href: "/services" }, { label: "Servizi", href: "/services" },
{ label: "About us", href: "/about" },
{ label: "Blog & Edugo", href: "/blog" }, { label: "Blog & Edugo", href: "/blog" },
{ label: "Contatti", href: "/contact" },
{ label: "Promo ed eventi", href: "/promo-eventi" }, { label: "Promo ed eventi", href: "/promo-eventi" },
{ label: "Contatti", href: "/contact" },
], ],
footerTraining: [ footerTraining: [
{ label: "One to one", href: "/training#one-to-one" }, { label: "One to one", href: "/training#one-to-one" },
+153 -33
View File
@@ -19,10 +19,17 @@ const user = tok ? getSessionUser(getDb(), tok) : null;
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin'); const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1'; const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
const showTags = canTags && wantTags; const showTags = canTags && wantTags;
// Google Tag Manager: l'ID container arriva da GTM_ID (.env), letto a runtime via process.env
// perché in SSR le PUBLIC_* di Vite verrebbero inlinate a build-time. Se vuoto GTM non carica.
const gtmId = process.env.GTM_ID;
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
<head> <head>
{gtmId && (
<script is:inline set:html={`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtmId}');`} />
)}
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title> <title>{title} — InsanityLab</title>
@@ -36,6 +43,9 @@ const showTags = canTags && wantTags;
<script is:inline>document.documentElement.classList.add('js');</script> <script is:inline>document.documentElement.classList.add('js');</script>
</head> </head>
<body> <body>
{gtmId && (
<noscript set:html={`<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
)}
<Header user={user} showTags={showTags} canTags={canTags} /> <Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main> <main><slot /></main>
<Footer /> <Footer />
@@ -77,37 +87,108 @@ const showTags = canTags && wantTags;
<style is:global> <style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; } [data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px; .tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; } background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; border-radius: 3px; }
.tag-badge:hover { opacity: 1; } .tag-badge:hover { opacity: 1; }
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; } .tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; border-radius: 2px; }
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; } .tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(28,20,16,.55); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center; padding: 20px; }
.tag-modal[hidden] { display: none; } .tag-modal[hidden] { display: none; }
.tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; } .tag-modal__box { background: #fdfbf7; color: #2b2320; width: min(600px, 94vw); max-height: 88vh; overflow: auto;
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; } border-radius: 14px; box-shadow: 0 24px 60px rgba(28,20,16,.35); font-family: system-ui, sans-serif; }
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; } .tag-modal__head { padding: 20px 24px 16px; border-bottom: 1px solid #ece4d8; }
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; } .tag-modal__head h3 { margin: 0; font: 600 1.05rem/1.3 system-ui, sans-serif; letter-spacing: .01em; }
.tag-modal__box select { margin: 6px 8px 6px 0; } .tag-modal__head .tm-tagchip { display: inline-block; margin-top: 8px; background: #efe8dc; color: #6b5b48;
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; } font: 500 11px/1.6 ui-monospace, monospace; padding: 2px 9px; border-radius: 999px; word-break: break-all; }
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; } .tag-modal__head .guid { font: 10px/1.5 ui-monospace, monospace; color: #a99f90; margin: 6px 0 0; }
.tag-modal__body { padding: 20px 24px; }
.tag-modal__box textarea { width: 100%; min-height: 150px; font: inherit; font-size: .95rem; line-height: 1.5;
padding: 12px 14px; box-sizing: border-box; border: 1px solid #dcd2c4; border-radius: 10px; background: #fff;
resize: vertical; color: #2b2320; }
.tag-modal__box textarea:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-styles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px 14px; margin-top: 18px; }
.tm-field { display: flex; flex-direction: column; gap: 5px; }
.tm-field__label { font: 600 11px/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: .08em; color: #8a7c68; }
.tm-field select { appearance: none; width: 100%; padding: 9px 30px 9px 12px; border: 1px solid #dcd2c4; border-radius: 9px;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%238a7c68' stroke-width='1.6'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E") no-repeat right 11px center;
font: inherit; font-size: .9rem; color: #2b2320; cursor: pointer; }
.tm-field select:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-field select.is-set { border-color: #9c8b70; background-color: #f6f1e8; font-weight: 600; }
.tm-field select.is-estimated { border-style: dashed; border-color: #c4b79f; color: #6b5b48; }
.tm-note { margin: 12px 0 0; font: 400 12px/1.5 system-ui, sans-serif; color: #8a7c68; }
.tag-modal__box input[type=file] { display: block; width: 100%; font: inherit; font-size: .9rem; padding: 10px;
border: 1px dashed #cbbfad; border-radius: 10px; background: #fff; box-sizing: border-box; }
.tm-del { margin-top: 12px; padding: 8px 14px; border: 0; border-radius: 8px; background: #b4443a; color: #fff; cursor: pointer; font: 600 .85rem system-ui, sans-serif; }
.tag-modal__msg { color: #b4443a; margin: 12px 24px 0; min-height: 18px; font-size: .88rem; }
.tag-modal__row { display: flex; gap: 10px; justify-content: flex-end; padding: 16px 24px 22px; }
.tag-modal__row button { padding: 10px 20px; border: 0; border-radius: 9px; cursor: pointer; font: 600 .9rem system-ui, sans-serif; }
.tag-modal__save { background: #3a2d26; color: #fff; } .tag-modal__save { background: #3a2d26; color: #fff; }
.tag-modal__cancel { background: #ddd; } .tag-modal__save:hover { background: #2b211b; }
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; } .tag-modal__cancel { background: #ece4d8; color: #5b4f42; }
.tag-modal__cancel:hover { background: #e0d6c6; }
</style> </style>
<div class="tag-modal" id="tag-modal" hidden> <div class="tag-modal" id="tag-modal" hidden>
<div class="tag-modal__box"> <div class="tag-modal__box">
<h3 id="tm-tag"></h3> <div class="tag-modal__head">
<p class="guid" id="tm-guid"></p> <h3>Modifica contenuto</h3>
<div id="tm-body"></div> <span class="tm-tagchip" id="tm-tag"></span>
<p class="guid" id="tm-guid"></p>
</div>
<div class="tag-modal__body" id="tm-body"></div>
<div class="tag-modal__msg" id="tm-msg"></div> <div class="tag-modal__msg" id="tm-msg"></div>
<div class="tag-modal__row"> <div class="tag-modal__row">
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button> <button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
</div> </div>
</div> </div>
</div> </div>
<script is:inline> <script is:inline>
(() => { (() => {
const STYLE_GROUPS = { size: ['s','m','l'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] }; const STYLE_GROUPS = { size: ['s','m','l','xl','xxl'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right','justify'] };
const GROUP_LABELS = { size: 'Dimensione', color: 'Colore', weight: 'Peso', style: 'Stile', align: 'Allineamento' };
const VALUE_LABELS = {
size: { s: 'S', m: 'M', l: 'L', xl: 'XL', xxl: 'XXL' },
weight: { normal: 'Normale', bold: 'Grassetto' },
style: { normal: 'Normale', italic: 'Corsivo' },
align: { left: 'Sinistra', center: 'Centro', right: 'Destra', justify: 'Giustificato' },
color: { accent: 'Accento', 'accent-dark': 'Accento scuro', dark: 'Scuro', heading: 'Titolo', text: 'Testo', 'text-light': 'Testo chiaro', white: 'Bianco' },
};
const valLabel = (g, v) => (VALUE_LABELS[g] && VALUE_LABELS[g][v]) || v;
// Stima il preset più vicino all'aspetto REALE dell'elemento (getComputedStyle).
const px = (s) => parseFloat(s) || 0;
const rgb = (s) => { const m = String(s).match(/-?\d+(\.\d+)?/g); return m ? m.slice(0, 3).map(Number) : null; };
const SIZE_SCALE = { s: 0.85, m: 1, l: 1.25, xl: 1.5, xxl: 2 };
let colorProbe = null;
function nearestSize(el) {
const cs = getComputedStyle(el);
const ref = el.parentElement ? px(getComputedStyle(el.parentElement).fontSize) : px(cs.fontSize);
const ratio = ref ? px(cs.fontSize) / ref : 1;
let best = 'm', bd = Infinity;
for (const k in SIZE_SCALE) { const d = Math.abs(SIZE_SCALE[k] - ratio); if (d < bd) { bd = d; best = k; } }
return best;
}
function nearestColor(el) {
const a = rgb(getComputedStyle(el).color); if (!a) return null;
if (!colorProbe) { colorProbe = document.createElement('span'); colorProbe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden'; document.body.appendChild(colorProbe); }
let best = null, bd = Infinity;
STYLE_GROUPS.color.forEach((c) => {
colorProbe.className = 'cs-color-' + c;
const p = rgb(getComputedStyle(colorProbe).color); if (!p) return;
const d = (p[0]-a[0])**2 + (p[1]-a[1])**2 + (p[2]-a[2])**2;
if (d < bd) { bd = d; best = c; }
});
colorProbe.className = '';
return best;
}
function effective(group, el) {
const cs = getComputedStyle(el);
if (group === 'size') return nearestSize(el);
if (group === 'color') return nearestColor(el);
if (group === 'weight') return parseInt(cs.fontWeight, 10) >= 600 ? 'bold' : 'normal';
if (group === 'style') return cs.fontStyle.indexOf('italic') >= 0 ? 'italic' : 'normal';
if (group === 'align') { let x = cs.textAlign; if (x === 'start') x = 'left'; else if (x === 'end') x = 'right'; return ['left','center','right','justify'].indexOf(x) >= 0 ? x : 'left'; }
return null;
}
const modal = document.getElementById('tag-modal'); const modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag'); const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid'); const tmGuid = document.getElementById('tm-guid');
@@ -137,8 +218,7 @@ const showTags = canTags && wantTags;
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file'; inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
tmBody.appendChild(inp); tmBody.appendChild(inp);
const del = document.createElement('button'); const del = document.createElement('button');
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del'; del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del'; del.className = 'tm-del';
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
tmBody.appendChild(del); tmBody.appendChild(del);
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' }); const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
@@ -148,18 +228,52 @@ const showTags = canTags && wantTags;
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.id = 'tm-value'; ta.value = data.value; ta.id = 'tm-value'; ta.value = data.value;
tmBody.appendChild(ta); tmBody.appendChild(ta);
(data.styleOptions || []).forEach((group) => { const styles = data.styles || {};
const opts = STYLE_GROUPS[group]; const groups = (data.styleOptions || []).filter((g) => STYLE_GROUPS[g]);
if (!opts) return; if (groups.length) {
const sel = document.createElement('select'); const grid = document.createElement('div');
sel.dataset.group = group; grid.className = 'tm-styles';
sel.className = 'tm-style'; let anyEstimate = false;
const none = document.createElement('option'); groups.forEach((group) => {
none.value = ''; none.textContent = group + ': —'; const explicit = styles[group]; // valore salvato nel blocco
sel.appendChild(none); const est = explicit ? null : effective(group, el); // altrimenti stima dall'aspetto reale
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); }); const shown = explicit || est;
tmBody.appendChild(sel); if (est) anyEstimate = true;
}); const field = document.createElement('label');
field.className = 'tm-field';
const cap = document.createElement('span');
cap.className = 'tm-field__label';
cap.textContent = GROUP_LABELS[group] || group;
const sel = document.createElement('select');
sel.dataset.group = group;
sel.className = 'tm-style';
if (est) sel.dataset.estimated = est; // marcata come stima: non fissata al salvataggio se invariata
const none = document.createElement('option');
none.value = ''; none.textContent = '— default (tema) —';
sel.appendChild(none);
STYLE_GROUPS[group].forEach((v) => {
const o = document.createElement('option');
o.value = v; o.textContent = valLabel(group, v) + (est === v ? ' (attuale)' : '');
if (shown === v) o.selected = true;
sel.appendChild(o);
});
sel.classList.add(explicit ? 'is-set' : (est ? 'is-estimated' : ''));
sel.addEventListener('change', () => {
delete sel.dataset.estimated; // scelta manuale → diventa esplicita
sel.classList.remove('is-estimated');
sel.classList.toggle('is-set', !!sel.value);
});
field.appendChild(cap); field.appendChild(sel);
grid.appendChild(field);
});
tmBody.appendChild(grid);
if (anyEstimate) {
const note = document.createElement('p');
note.className = 'tm-note';
note.textContent = 'I valori "(attuale)" riflettono laspetto dal tema: cambiane uno per fissarlo sul blocco.';
tmBody.appendChild(note);
}
}
} }
modal.hidden = false; modal.hidden = false;
} }
@@ -176,10 +290,16 @@ const showTags = canTags && wantTags;
return; return;
} }
const value = document.getElementById('tm-value').value; const value = document.getElementById('tm-value').value;
const selects = document.querySelectorAll('.tm-style');
const styles = {}; const styles = {};
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; }); selects.forEach((s) => {
const v = s.value;
if (!v) return; // "default (tema)" → non impostato
if (s.dataset.estimated && v === s.dataset.estimated) return; // stima non modificata → non fissare
styles[s.dataset.group] = v;
});
const body = { value }; const body = { value };
if (Object.keys(styles).length) body.styles = styles; if (selects.length) body.styles = styles; // inviato sempre (anche {}) così l'azzeramento persiste
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), { const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}); });
+2 -2
View File
@@ -1,9 +1,9 @@
export const STYLE_GROUPS = { export const STYLE_GROUPS = {
size: ['s', 'm', 'l'], size: ['s', 'm', 'l', 'xl', 'xxl'],
color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'], color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'],
weight: ['normal', 'bold'], weight: ['normal', 'bold'],
style: ['normal', 'italic'], style: ['normal', 'italic'],
align: ['left', 'center', 'right'], align: ['left', 'center', 'right', 'justify'],
} as const; } as const;
export type StyleGroup = keyof typeof STYLE_GROUPS; export type StyleGroup = keyof typeof STYLE_GROUPS;
+8 -2
View File
@@ -10,14 +10,20 @@ const json = (status: number, body: object) =>
export const GET: APIRoute = ({ params }) => { export const GET: APIRoute = ({ params }) => {
const tag = params.tag ?? ''; const tag = params.tag ?? '';
const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as const row = getDb().prepare('SELECT tag, type, value, styles, guid FROM content_blocks WHERE tag = ?').get(tag) as
{ tag: string; type: string; value: string; guid: string | null } | undefined; { tag: string; type: string; value: string; styles: string; guid: string | null } | undefined;
if (!row) return json(404, { error: 'Tag inesistente.' }); if (!row) return json(404, { error: 'Tag inesistente.' });
let styles: Record<string, string> = {};
try {
const parsed = JSON.parse(row.styles);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) styles = parsed;
} catch { /* styles corrotto: si ignora */ }
return json(200, { return json(200, {
tag: row.tag, tag: row.tag,
guid: row.guid ?? '', guid: row.guid ?? '',
type: row.type, type: row.type,
value: row.value, value: row.value,
styles,
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [], styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
}); });
}; };
+54 -110
View File
@@ -1,142 +1,86 @@
--- ---
import Base from '../layouts/Base.astro'; 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). // 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, // 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: // 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. // 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'; const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
export const prerender = false; 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 = [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');
--- ---
<Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe."> <Base title="Promo ed eventi — InsanityLab" description="Edizione apertura: pacchetti a 5 incontri per One to One, Small Group, Reformer e Holistic Class. Acquisto online, pagamento sicuro via Stripe.">
<div class="lp"> <div class="lp">
<div class="page"> <div class="page">
<section class="hero"> <section class="hero">
<div class="badge">insanity.lab · bologna savena</div> <T tag="promo.hero.badge" as="div" class="badge" />
<h1>Allenati con metodo.</h1> <T tag="promo.hero.title1" as="h1" />
<p class="accent">Scegli come</p> <T tag="promo.hero.accent" as="p" class="accent" />
<h1>vuoi farlo.</h1> <T tag="promo.hero.title2" as="h1" />
<p class="desc">Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.</p> <T tag="promo.hero.desc" as="p" class="desc" />
<a class="btn-sand" href="#proposte">LE NOSTRE PROPOSTE →</a> <a class="btn-sand" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
</section> </section>
<section class="launch"> <section class="launch">
<div class="badge">solo per apertura nuova sede Settembre 2026</div> <T tag="promo.launch.badge" as="div" class="badge" />
<h1>Il nostro metodo nasce per durare.</h1> <T tag="promo.launch.title" as="h1" />
<p class="accent">Per l'apertura, un'eccezione. Solo per poco.</p> <T tag="promo.launch.accent" as="p" class="accent" />
<p class="desc">In-Sanity Lab lavora per percorsi strutturati — trimestrali, semestrali — perché i risultati veri si costruiscono nel tempo. Per la nuova apertura, ci sarà un'eccezione: 5 incontri pensati come un vero mini-percorso, non semplici lezioni sciolte.</p> <T tag="promo.launch.desc" as="p" class="desc" />
</section> </section>
<section class="why5"> <section class="why5">
<p>Non è una prova. È un percorso in miniatura — per capire come lavoriamo prima di impegnarti su un programma trimestrale o semestrale.</p> <T tag="promo.why5.text" as="p" />
</section> </section>
<section class="cards-wrap" id="proposte"> <section class="cards-wrap" id="proposte">
<div class="cards"> <div class="cards">
{cards.map((c) => (
<div class="card"> <div class:list={['card', c.featured && 'featured']}>
<div class="eyebrow">Personal training</div> {c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
<div class="title">One to One</div> <T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
<div class="price-row"> <T tag={`promo.card.${c.id}.title`} as="div" class="title" />
<div class="price">163 €</div> <div class="price-row">
<div class="price-old">300 €</div> <T tag={`promo.card.${c.id}.price`} as="div" class="price" />
<T tag={`promo.card.${c.id}.price-old`} as="div" class="price-old" />
</div>
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
<ul>
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
<T tag={`promo.card.${c.id}.feature.${m}`} as="li" />
))}
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></a>
</div> </div>
<div class="unit">5 incontri</div> ))}
<ul>
<li>Screening iniziale, definizione dei tuoi obiettivi</li>
<li>Check-up con profilazione fisica, il tuo punto di partenza</li>
<li>PT dedicato, presente a ogni sessione</li>
<li>In-sanity plan, il tuo programma personalizzato</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
<div class="card featured">
<div class="featured-badge">Più scelto</div>
<div class="eyebrow">Personal training</div>
<div class="title">Small Group</div>
<div class="price-row">
<div class="price">110 €</div>
<div class="price-old">220 €</div>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Screening iniziale, definizione dei tuoi obiettivi</li>
<li>Check-up con profilazione fisica, il tuo punto di partenza</li>
<li>PT dedicato, presente a ogni sessione</li>
<li>In-sanity plan, il tuo programma personalizzato</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
<div class="card">
<div class="eyebrow">Pilates class</div>
<div class="title">Reformer</div>
<div class="price-row">
<div class="price">100 €</div>
<div class="price-old">140 €</div>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Core, postura e mobilità su macchina Reformer</li>
<li>Lavoro a basso impatto sulle articolazioni</li>
<li>Percorso breve, non lezioni isolate</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
<div class="card">
<div class="eyebrow">Holistic class</div>
<div class="title">Pilates Mat, Mobility, Yoga</div>
<div class="price-row">
<div class="price">50 €</div>
<div class="price-old">100 €</div>
</div>
<div class="unit">5 incontri</div>
<ul>
<li>Mobilità articolare, respirazione, equilibrio</li>
<li>Da solo, o integrato nei tuoi allenamenti</li>
<li>Adatto a tutti i livelli</li>
</ul>
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener">ACQUISTA →</a>
</div>
</div> </div>
<p class="validity-note">Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026</p> <T tag="promo.validity-note" as="p" class="validity-note" />
</section> </section>
<section class="faq-wrap"> <section class="faq-wrap">
<h1>Tutto quello che vuoi sapere prima di acquistare.</h1> <T tag="promo.faq.title" as="h1" />
{faqs.map((n) => {
<div class="faq-block"> const a = resolveContent(`promo.faq.${n}.a`);
<div class="faq-q"><p>Come funziona il pacchetto a incontri per One to One e Small Group?</p><div class="faq-toggle">+</div></div> return (
<p class="faq-a">Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi ci scrivi su <span class="hl">WhatsApp (351 753 5977)</span> per prenotare le singole sessioni in base agli orari disponibili. 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.</p> <div class="faq-block">
</div> <div class="faq-q"><T tag={`promo.faq.${n}.q`} as="p" /><div class="faq-toggle">+</div></div>
<p class:list={['faq-a', a.classes]} data-tag={`promo.faq.${n}.a`} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
<div class="faq-block"> </div>
<div class="faq-q"><p>Come funziona il percorso Reformer o Holistic Class?</p><div class="faq-toggle">+</div></div> );
<p class="faq-a">Acquisti il pacchetto online con pagamento sicuro via <span class="hl">Stripe</span>. Ricevi una conferma via email. Poi vai nella schermata Home, clicca il servizio che hai acquistato e seleziona autonomamente la class nel giorno e nell'orario che desideri. In caso di problemi contattaci su <span class="hl">WhatsApp (351 753 5977)</span>! Gli incontri sono validi 2 mesi dalla data del primo incontro e comunque non oltre il 1° dicembre 2026.</p> })}
</div>
<div class="faq-block">
<div class="faq-q"><p>Posso usare gli incontri su servizi diversi?</p><div class="faq-toggle">+</div></div>
<p class="faq-a">Ogni pacchetto è dedicato a un singolo servizio (<span class="hl">One to One</span>, <span class="hl">Small Group</span>, <span class="hl">Reformer</span> o <span class="hl">Holistic Class</span>). Se vuoi accedere a più servizi puoi acquistare pacchetti separati per ciascuno.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Posso regalare un pacchetto a qualcun altro?</p><div class="faq-toggle">+</div></div>
<p class="faq-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 <span class="hl">WhatsApp (351 753 5977)</span> per organizzare le sessioni.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Il pagamento è sicuro?</p><div class="faq-toggle">+</div></div>
<p class="faq-a">Sì. Il pagamento avviene tramite <span class="hl">Stripe</span>, 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.</p>
</div>
<div class="faq-block">
<div class="faq-q"><p>Posso chiedere un rimborso?</p><div class="faq-toggle">+</div></div>
<p class="faq-a">Per richieste di rimborso o problemi con l'acquisto scrivici su <span class="hl">WhatsApp</span> o via email. Valutiamo ogni situazione in modo diretto e umano.</p>
</div>
</section> </section>
</div> </div>
+3
View File
@@ -73,6 +73,8 @@ html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1
.cs-size-s { font-size: .85em; } .cs-size-s { font-size: .85em; }
.cs-size-m { font-size: 1em; } .cs-size-m { font-size: 1em; }
.cs-size-l { font-size: 1.25em; } .cs-size-l { font-size: 1.25em; }
.cs-size-xl { font-size: 1.5em; }
.cs-size-xxl { font-size: 2em; }
.cs-color-accent { color: var(--c-accent); } .cs-color-accent { color: var(--c-accent); }
.cs-color-accent-dark { color: var(--c-accent-dark); } .cs-color-accent-dark { color: var(--c-accent-dark); }
.cs-color-dark { color: var(--c-dark); } .cs-color-dark { color: var(--c-dark); }
@@ -87,3 +89,4 @@ html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1
.cs-align-left { text-align: left; } .cs-align-left { text-align: left; }
.cs-align-center { text-align: center; } .cs-align-center { text-align: center; }
.cs-align-right { text-align: right; } .cs-align-right { text-align: right; }
.cs-align-justify { text-align: justify; }