Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a30bc08b9 | |||
| bcc965655d | |||
| 8d593dce90 | |||
| 47a93049bb | |||
| 5ff47711cd |
@@ -11,3 +11,7 @@ CONTACT_FROM=postmaster@insanitylab.it
|
||||
CONTACT_TO=info@insanitylab.it
|
||||
DB_PATH=data/insanitylab.db
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ export const contentSeed: SeedEntry[] = [
|
||||
T('global.contact.address', site.address, []),
|
||||
T('global.contact.city-line', site.cityLine, []),
|
||||
T('global.contact.map-query', site.mapQuery, []),
|
||||
T('global.whatsapp', '351 753 5977', []),
|
||||
T('global.hours.1', site.hours[0], []),
|
||||
T('global.hours.2', site.hours[1], []),
|
||||
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.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]));
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@ export const site = {
|
||||
{ label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
|
||||
],
|
||||
navigation: [
|
||||
{ label: "About us", href: "/about" },
|
||||
{ label: "Training", href: "/training" },
|
||||
{ label: "Servizi", href: "/services" },
|
||||
{ label: "About us", href: "/about" },
|
||||
{ label: "Blog & Edugo", href: "/blog" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
{ label: "Promo ed eventi", href: "/promo-eventi" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
],
|
||||
footerTraining: [
|
||||
{ label: "One to one", href: "/training#one-to-one" },
|
||||
|
||||
+145
-25
@@ -19,10 +19,17 @@ const user = tok ? getSessionUser(getDb(), tok) : null;
|
||||
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
|
||||
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
|
||||
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>
|
||||
<html lang="it">
|
||||
<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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — InsanityLab</title>
|
||||
@@ -36,6 +43,9 @@ const showTags = canTags && wantTags;
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<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} />
|
||||
<main><slot /></main>
|
||||
<Footer />
|
||||
@@ -77,37 +87,108 @@ const showTags = canTags && wantTags;
|
||||
<style is:global>
|
||||
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
|
||||
.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__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
|
||||
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
|
||||
.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(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__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
|
||||
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
|
||||
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
|
||||
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
|
||||
.tag-modal__box select { margin: 6px 8px 6px 0; }
|
||||
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
|
||||
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
|
||||
.tag-modal__box { background: #fdfbf7; color: #2b2320; width: min(600px, 94vw); max-height: 88vh; overflow: auto;
|
||||
border-radius: 14px; box-shadow: 0 24px 60px rgba(28,20,16,.35); font-family: system-ui, sans-serif; }
|
||||
.tag-modal__head { padding: 20px 24px 16px; border-bottom: 1px solid #ece4d8; }
|
||||
.tag-modal__head h3 { margin: 0; font: 600 1.05rem/1.3 system-ui, sans-serif; letter-spacing: .01em; }
|
||||
.tag-modal__head .tm-tagchip { display: inline-block; margin-top: 8px; background: #efe8dc; color: #6b5b48;
|
||||
font: 500 11px/1.6 ui-monospace, monospace; padding: 2px 9px; border-radius: 999px; word-break: break-all; }
|
||||
.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__cancel { background: #ddd; }
|
||||
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
|
||||
.tag-modal__save:hover { background: #2b211b; }
|
||||
.tag-modal__cancel { background: #ece4d8; color: #5b4f42; }
|
||||
.tag-modal__cancel:hover { background: #e0d6c6; }
|
||||
</style>
|
||||
<div class="tag-modal" id="tag-modal" hidden>
|
||||
<div class="tag-modal__box">
|
||||
<h3 id="tm-tag"></h3>
|
||||
<div class="tag-modal__head">
|
||||
<h3>Modifica contenuto</h3>
|
||||
<span class="tm-tagchip" id="tm-tag"></span>
|
||||
<p class="guid" id="tm-guid"></p>
|
||||
<div id="tm-body"></div>
|
||||
</div>
|
||||
<div class="tag-modal__body" id="tm-body"></div>
|
||||
<div class="tag-modal__msg" id="tm-msg"></div>
|
||||
<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__save" id="tm-save">Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
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 tmTag = document.getElementById('tm-tag');
|
||||
const tmGuid = document.getElementById('tm-guid');
|
||||
@@ -137,8 +218,7 @@ const showTags = canTags && wantTags;
|
||||
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
|
||||
tmBody.appendChild(inp);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
|
||||
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
|
||||
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del'; del.className = 'tm-del';
|
||||
tmBody.appendChild(del);
|
||||
del.addEventListener('click', async () => {
|
||||
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');
|
||||
ta.id = 'tm-value'; ta.value = data.value;
|
||||
tmBody.appendChild(ta);
|
||||
(data.styleOptions || []).forEach((group) => {
|
||||
const opts = STYLE_GROUPS[group];
|
||||
if (!opts) return;
|
||||
const styles = data.styles || {};
|
||||
const groups = (data.styleOptions || []).filter((g) => STYLE_GROUPS[g]);
|
||||
if (groups.length) {
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'tm-styles';
|
||||
let anyEstimate = false;
|
||||
groups.forEach((group) => {
|
||||
const explicit = styles[group]; // valore salvato nel blocco
|
||||
const est = explicit ? null : effective(group, el); // altrimenti stima dall'aspetto reale
|
||||
const shown = explicit || est;
|
||||
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 = group + ': —';
|
||||
none.value = ''; none.textContent = '— default (tema) —';
|
||||
sel.appendChild(none);
|
||||
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
|
||||
tmBody.appendChild(sel);
|
||||
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 l’aspetto dal tema: cambiane uno per fissarlo sul blocco.';
|
||||
tmBody.appendChild(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
modal.hidden = false;
|
||||
}
|
||||
@@ -176,10 +290,16 @@ const showTags = canTags && wantTags;
|
||||
return;
|
||||
}
|
||||
const value = document.getElementById('tm-value').value;
|
||||
const selects = document.querySelectorAll('.tm-style');
|
||||
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 };
|
||||
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), {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -10,14 +10,20 @@ const json = (status: number, body: object) =>
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const tag = params.tag ?? '';
|
||||
const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as
|
||||
{ tag: string; type: string; value: string; guid: string | null } | undefined;
|
||||
const row = getDb().prepare('SELECT tag, type, value, styles, guid FROM content_blocks WHERE tag = ?').get(tag) as
|
||||
{ tag: string; type: string; value: string; styles: string; guid: string | null } | undefined;
|
||||
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, {
|
||||
tag: row.tag,
|
||||
guid: row.guid ?? '',
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
styles,
|
||||
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
+48
-104
@@ -1,142 +1,86 @@
|
||||
---
|
||||
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 = [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.">
|
||||
<div class="lp">
|
||||
<div class="page">
|
||||
|
||||
<section class="hero">
|
||||
<div class="badge">insanity.lab · bologna savena</div>
|
||||
<h1>Allenati con metodo.</h1>
|
||||
<p class="accent">Scegli come</p>
|
||||
<h1>vuoi farlo.</h1>
|
||||
<p class="desc">Allenati con flessibilità, senza vincoli, da subito.<br>Acquisto online in pochi secondi, pagamento sicuro via Stripe.</p>
|
||||
<a class="btn-sand" href="#proposte">LE NOSTRE PROPOSTE →</a>
|
||||
<T tag="promo.hero.badge" as="div" class="badge" />
|
||||
<T tag="promo.hero.title1" as="h1" />
|
||||
<T tag="promo.hero.accent" as="p" class="accent" />
|
||||
<T tag="promo.hero.title2" as="h1" />
|
||||
<T tag="promo.hero.desc" as="p" class="desc" />
|
||||
<a class="btn-sand" href="#proposte"><T tag="promo.hero.cta" as="span" /></a>
|
||||
</section>
|
||||
|
||||
<section class="launch">
|
||||
<div class="badge">solo per apertura nuova sede Settembre 2026</div>
|
||||
<h1>Il nostro metodo nasce per durare.</h1>
|
||||
<p class="accent">Per l'apertura, un'eccezione. Solo per poco.</p>
|
||||
<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.badge" as="div" class="badge" />
|
||||
<T tag="promo.launch.title" as="h1" />
|
||||
<T tag="promo.launch.accent" as="p" class="accent" />
|
||||
<T tag="promo.launch.desc" as="p" class="desc" />
|
||||
</section>
|
||||
|
||||
<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 class="cards-wrap" id="proposte">
|
||||
<div class="cards">
|
||||
|
||||
<div class="card">
|
||||
<div class="eyebrow">Personal training</div>
|
||||
<div class="title">One to One</div>
|
||||
{cards.map((c) => (
|
||||
<div class:list={['card', c.featured && 'featured']}>
|
||||
{c.featured && <T tag={`promo.card.${c.id}.badge`} as="div" class="featured-badge" />}
|
||||
<T tag={`promo.card.${c.id}.eyebrow`} as="div" class="eyebrow" />
|
||||
<T tag={`promo.card.${c.id}.title`} as="div" class="title" />
|
||||
<div class="price-row">
|
||||
<div class="price">163 €</div>
|
||||
<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>
|
||||
<div class="unit">5 incontri</div>
|
||||
<T tag={`promo.card.${c.id}.unit`} as="div" class="unit" />
|
||||
<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>
|
||||
{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">ACQUISTA →</a>
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag="promo.buy-label" as="span" /></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>
|
||||
<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 class="faq-wrap">
|
||||
<h1>Tutto quello che vuoi sapere prima di acquistare.</h1>
|
||||
|
||||
<T tag="promo.faq.title" as="h1" />
|
||||
{faqs.map((n) => {
|
||||
const a = resolveContent(`promo.faq.${n}.a`);
|
||||
return (
|
||||
<div class="faq-block">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="faq-block">
|
||||
<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 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>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user