Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts: # apps/sito/compose.yaml
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import T from '../components/content/T.astro';
|
||||
import { t } from '../lib/content';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title={t('notfound.meta.title')}>
|
||||
<section class="section" style="text-align:center; padding-block:140px;">
|
||||
<div class="container">
|
||||
<T tag="notfound.eyebrow" as="p" class="eyebrow" />
|
||||
<T tag="notfound.title" as="h1" />
|
||||
<T tag="notfound.body" as="p" />
|
||||
<a class="btn" href="/">{t('notfound.cta')}</a>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
import { team } from '../data/team';
|
||||
import T from '../components/content/T.astro';
|
||||
import TImg from '../components/content/TImg.astro';
|
||||
import { t } from '../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
|
||||
const founders = team.filter((m) => m.founder);
|
||||
const staff = team.filter((m) => !m.founder);
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title={t('about.meta.title')} description={t('about.meta.description')}>
|
||||
<PageHero title={t('about.hero.title')} />
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="about-img-row">
|
||||
<div class="about-img-row__text section-heading">
|
||||
<T tag="about.mission.title" as="h2" />
|
||||
<T tag="about.mission.body" as="p" />
|
||||
</div>
|
||||
{imgOf('about-training-group') && (
|
||||
<div class="about-img-row__img">
|
||||
<TImg tag="about.mission.image" src={imgOf('about-training-group')!} alt="Training group InsanityLab" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--alt">
|
||||
<div class="container about-2col">
|
||||
<div>
|
||||
<T tag="about.direzione.title" as="h2" />
|
||||
<T tag="about.direzione.body" as="p" />
|
||||
<a class="btn" href="/services">{t('about.direzione.cta')}</a>
|
||||
</div>
|
||||
<div>
|
||||
<T tag="about.approccio.title" as="h2" />
|
||||
<T tag="about.approccio.body" as="p" />
|
||||
<a class="btn btn--dark" href="/contact">{t('about.approccio.cta')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="about-img-row about-img-row--reverse">
|
||||
<div class="about-img-row__text section-heading">
|
||||
<T tag="about.about.title" as="h2" />
|
||||
<T tag="about.about.body" as="p" />
|
||||
</div>
|
||||
{imgOf('about-pushup') && (
|
||||
<div class="about-img-row__img about-img-row__img--native">
|
||||
<TImg tag="about.about.image" src={imgOf('about-pushup')!} alt="Allenamento InsanityLab" widths={[320, 640]} sizes="320px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--dark">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="about.convenzioni.title" as="h2" />
|
||||
<T tag="about.convenzioni.body" as="p" />
|
||||
</div>
|
||||
<ul class="conv">
|
||||
<T tag="about.convenzioni.item1" as="li" />
|
||||
<T tag="about.convenzioni.item2" as="li" />
|
||||
<T tag="about.convenzioni.item3" as="li" />
|
||||
</ul>
|
||||
<p class="conv__cta">{t('about.convenzioni.cta-text')} {t('global.contact.email')} · {t('global.contact.phone')}</p>
|
||||
{imgOf('about-conventions') && (
|
||||
<div class="round-img">
|
||||
<TImg tag="about.convenzioni.image" src={imgOf('about-conventions')!} alt="Convenzioni aziendali" widths={[360, 720]} sizes="360px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="about.team.title" as="h2" />
|
||||
<T tag="about.team.body" as="p" />
|
||||
</div>
|
||||
<div class="tgrid tgrid--founders">
|
||||
{founders.map((m) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<article class="tcard">
|
||||
{m.imageAbout && imgOf(m.imageAbout) && <TImg tag={`team.${slug}.image-about`} src={imgOf(m.imageAbout)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
|
||||
<T tag={`team.${slug}.short-name`} as="h3" />
|
||||
<T tag={`team.${slug}.role`} as="p" class="tcard__role" />
|
||||
<T tag={`team.${slug}.bio`} as="p" />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p class="section-heading" style="margin-top:60px">{t('about.team.staff-intro')}</p>
|
||||
<div class="tgrid">
|
||||
{staff.map((m) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<article class="tcard">
|
||||
{m.imageAbout && imgOf(m.imageAbout) && <TImg tag={`team.${slug}.image-about`} src={imgOf(m.imageAbout)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
|
||||
<T tag={`team.${slug}.short-name`} as="h3" />
|
||||
<T tag={`team.${slug}.role`} as="p" class="tcard__role" />
|
||||
<T tag={`team.${slug}.bio`} as="p" />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="about-img-row">
|
||||
<div class="about-img-row__text section-heading">
|
||||
<T tag="about.studios.title" as="h2" />
|
||||
<T tag="about.studios.body1" as="p" />
|
||||
<T tag="about.studios.body2" as="p" />
|
||||
<T tag="about.studios.body3" as="p" />
|
||||
</div>
|
||||
{imgOf('about-studios') && (
|
||||
<div class="about-img-row__img">
|
||||
<TImg tag="about.studios.image" src={imgOf('about-studios')!} alt="In-Sanity Studios" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.about-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; }
|
||||
.about-img-row { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
|
||||
.about-img-row--reverse { direction: rtl; }
|
||||
.about-img-row--reverse > * { direction: ltr; }
|
||||
.about-img-row__img img { width: 100%; height: auto; display: block; }
|
||||
.about-img-row__img--native { max-width: 320px; margin: 0 auto; }
|
||||
.conv { max-width: 760px; margin: 0 auto 30px; display: grid; gap: 18px; padding-left: 20px; }
|
||||
.conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
|
||||
.round-img { max-width: 380px; margin: 30px auto 0; }
|
||||
.round-img img { border-radius: 50%; aspect-ratio: 1; object-fit: cover; width: 100%; }
|
||||
/* pre-line preserva gli a-capo del testo (il <p> reso da <T> non eredita il cid → :global). */
|
||||
.about-img-row__text :global([data-tag="about.studios.body3"]) { white-space: pre-line; }
|
||||
/* flex + center così l'ultima riga incompleta (es. staff: 5 membri → 2 nell'ultima riga)
|
||||
resta centrata invece di appoggiarsi a sinistra. */
|
||||
.tgrid { display: flex; flex-wrap: wrap; justify-content: center; gap: 40px; }
|
||||
.tcard { width: calc((100% - 80px) / 3); }
|
||||
/* :global(img) perché gli <img> di override resi da <TImg> non ereditano il cid. */
|
||||
.tcard :global(img) { aspect-ratio: 3/4; object-fit: cover; width: 100%; margin-bottom: 16px; }
|
||||
.tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
|
||||
.tcard__role { font-size: .8rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
||||
.tcard p { font-size: .9rem; }
|
||||
@media (max-width: 767px) {
|
||||
.about-2col,
|
||||
.about-img-row { grid-template-columns: 1fr; }
|
||||
.tcard { width: 100%; }
|
||||
.about-img-row--reverse { direction: ltr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
// Cestino degli articoli: qui si recupera ciò che è stato tolto dal sito, o lo si elimina
|
||||
// per sempre. Chi gestisce vede tutto, gli altri solo i propri articoli.
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listTrashed } from '../../lib/posts';
|
||||
import { listUsers } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
const user = Astro.locals.user!;
|
||||
const isManager = user.role === 'superuser' || user.role === 'admin';
|
||||
const posts = listTrashed(getDb(), isManager ? undefined : user.id);
|
||||
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||||
---
|
||||
<Admin title="Cestino">
|
||||
<h1>Cestino</h1>
|
||||
<p class="atoolbar"><a class="abtn abtn--ghost" href="/admin">← Torna agli articoli</a></p>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p class="amuted">Il cestino è vuoto.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Nel cestino dal</th><th class="uactions">Azioni</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((p) => (
|
||||
<tr>
|
||||
<td>{p.title}</td>
|
||||
<td>{p.category}</td>
|
||||
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
|
||||
<td>{p.deleted_at ? fmt.format(new Date(p.deleted_at.replace(' ', 'T') + 'Z')) : '—'}</td>
|
||||
<td class="uactions">
|
||||
<button class="aicon" data-restore={p.id} title="Ripristina" aria-label={`Ripristina ${p.title}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z"/></svg>
|
||||
</button>
|
||||
<button class="aicon aicon--danger" data-purge={p.id} title="Elimina definitivamente" aria-label={`Elimina definitivamente ${p.title}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p class="form-msg" id="row-msg"></p>
|
||||
</Admin>
|
||||
|
||||
<script>
|
||||
const rowMsg = document.getElementById('row-msg')!;
|
||||
const errore = async (res: Response) => {
|
||||
rowMsg.className = 'form-msg form-msg--err';
|
||||
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
|
||||
};
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
const res = await fetch(`/api/admin/posts/${b.dataset.restore}`, { method: 'POST' });
|
||||
if (res.ok) location.reload();
|
||||
else errore(res);
|
||||
}));
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-purge]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm('Eliminare definitivamente questo articolo? Il contenuto sarà perso.')) return;
|
||||
const res = await fetch(`/api/admin/posts/${b.dataset.purge}?definitivo=1`, { method: 'DELETE' });
|
||||
if (res.ok) location.reload();
|
||||
else errore(res);
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { seedByTag } from '../../data/content-seed';
|
||||
import { STYLE_GROUPS } from '../../lib/style-presets';
|
||||
export const prerender = false;
|
||||
|
||||
// Mappa esplicita prefisso tag → gruppo del pannello (spec §7).
|
||||
const GROUP_BY_PREFIX: Record<string, string> = {
|
||||
home: 'Home',
|
||||
about: 'About', team: 'About', method: 'About',
|
||||
service: 'Servizi', services: 'Servizi', pricing: 'Servizi',
|
||||
training: 'Training',
|
||||
contact: 'Contatti', form: 'Contatti',
|
||||
global: 'Globali', header: 'Globali', footer: 'Globali',
|
||||
agenda: 'Globali', partner: 'Globali', notfound: 'Globali',
|
||||
};
|
||||
const GROUP_ORDER = ['Home', 'About', 'Servizi', 'Training', 'Contatti', 'Globali', 'Altro'];
|
||||
|
||||
interface Row { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }
|
||||
const rows = getDb().prepare(
|
||||
'SELECT tag, type, value, styles, updated_at, updated_by FROM content_blocks ORDER BY tag'
|
||||
).all() as Row[];
|
||||
|
||||
interface Item extends Row { parsedStyles: Record<string, string>; styleOptions: readonly string[] }
|
||||
const groups = new Map<string, Item[]>(GROUP_ORDER.map((g) => [g, []]));
|
||||
for (const r of rows) {
|
||||
let parsedStyles: Record<string, string> = {};
|
||||
try { parsedStyles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
|
||||
if (typeof parsedStyles !== 'object' || parsedStyles === null) parsedStyles = {};
|
||||
const group = GROUP_BY_PREFIX[r.tag.split('.')[0]] ?? 'Altro';
|
||||
groups.get(group)!.push({ ...r, parsedStyles, styleOptions: seedByTag.get(r.tag)?.styleOptions ?? [] });
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||||
const snippet = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}…` : v);
|
||||
const isLong = (v: string) => v.length > 80 || v.includes('\n');
|
||||
const imgLabel = (v: string) => (v ? '(immagine personalizzata)' : '(immagine originale)');
|
||||
const allStyles = STYLE_GROUPS as Record<string, readonly string[]>;
|
||||
---
|
||||
<Admin title="Contenuti">
|
||||
<div class="chead">
|
||||
<h1>Contenuti</h1>
|
||||
<a href="/?annotate=1" target="_blank">Vedi sito con tag ↗</a>
|
||||
</div>
|
||||
<input class="afield" id="csearch" type="search" placeholder="Cerca per tag o contenuto…" aria-label="Cerca per tag o contenuto" autocomplete="off" />
|
||||
|
||||
<div id="cgroups">
|
||||
{GROUP_ORDER.map((g) => {
|
||||
const items = groups.get(g)!;
|
||||
return items.length > 0 && (
|
||||
<details class="cgroup">
|
||||
<summary><strong>{g}</strong> <span class="pill">{items.length}</span></summary>
|
||||
{items.map((it) => (
|
||||
<details class="crow" id={it.tag} data-tag={it.tag} data-q={`${it.tag} ${it.value}`.toLowerCase()}>
|
||||
<summary>
|
||||
<code>{it.tag}</code>
|
||||
<span class="crow-snippet">{it.type === 'image' ? imgLabel(it.value) : snippet(it.value)}</span>
|
||||
</summary>
|
||||
<div class="crow-body">
|
||||
{it.type === 'image' ? (
|
||||
<>
|
||||
<p class="cimg-preview">
|
||||
{it.value
|
||||
? <img src={`/uploads/${it.value}`} alt={it.tag} />
|
||||
: <em>Nessun override: il sito usa l'immagine originale.</em>}
|
||||
</p>
|
||||
<p class="cactions">
|
||||
<input type="file" accept="image/*" aria-label={`Nuova immagine per ${it.tag}`} data-file />
|
||||
<button class="abtn" type="button" data-upload>Carica</button>
|
||||
<button class="abtn abtn--danger" type="button" data-restore>Ripristina originale</button>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLong(it.value)
|
||||
? <textarea class="afield" rows="4" aria-label={`Contenuto ${it.tag}`} data-value>{it.value}</textarea>
|
||||
: <input class="afield" value={it.value} aria-label={`Contenuto ${it.tag}`} data-value />}
|
||||
{it.styleOptions.length > 0 && (
|
||||
<p class="cstyles">
|
||||
{it.styleOptions.map((sg) => (
|
||||
<label>{sg}
|
||||
<select data-style={sg}>
|
||||
<option value="">(default)</option>
|
||||
{allStyles[sg].map((v) => (
|
||||
<option value={v} selected={it.parsedStyles[sg] === v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p class="cactions"><button class="abtn" type="button" data-save>Salva</button></p>
|
||||
</>
|
||||
)}
|
||||
<p class="form-msg" data-msg hidden></p>
|
||||
{it.updated_by && (
|
||||
<p class="cmeta">Aggiornato {fmt.format(new Date(it.updated_at.replace(' ', 'T') + 'Z'))} da {it.updated_by}</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p id="cempty" hidden>Nessun contenuto corrisponde alla ricerca.</p>
|
||||
</Admin>
|
||||
|
||||
<style is:global>
|
||||
.chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||
.chead a { color: var(--c-accent-dark); font-weight: 600; text-decoration: none; }
|
||||
.cgroup { background: #fff; border: 1px solid #e4dfd7; margin-bottom: 14px; }
|
||||
.cgroup > summary {
|
||||
padding: 13px 16px; cursor: pointer; background: #faf8f5; border-bottom: 1px solid #efeae2;
|
||||
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--c-heading);
|
||||
}
|
||||
.crow { border-top: 1px solid #efeae2; }
|
||||
.crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; }
|
||||
.crow > summary code { background: #f2ede5; padding: 2px 7px; font-size: .78rem; white-space: nowrap; color: #6f6357; }
|
||||
.crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.crow-body { padding: 6px 16px 12px; }
|
||||
.crow-body .afield { margin-bottom: 8px; }
|
||||
.cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; }
|
||||
.cstyles label { font-size: .82rem; color: var(--c-text-light); }
|
||||
.cstyles select { margin-left: 4px; padding: 4px; font: inherit; }
|
||||
.cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #e4dfd7; }
|
||||
.cmeta { color: var(--c-text-light); font-size: .76rem; margin: 4px 0 0; }
|
||||
.crow--hl { outline: 2px solid var(--c-accent); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const wrap = document.getElementById('cgroups')!;
|
||||
|
||||
function showMsg(row: HTMLElement, ok: boolean, text: string): void {
|
||||
const m = row.querySelector<HTMLElement>('[data-msg]')!;
|
||||
m.textContent = text;
|
||||
m.hidden = false;
|
||||
m.className = `form-msg ${ok ? 'form-msg--ok' : 'form-msg--err'}`;
|
||||
}
|
||||
|
||||
function setPreview(row: HTMLElement, url: string | null): void {
|
||||
const p = row.querySelector<HTMLElement>('.cimg-preview')!;
|
||||
if (url) {
|
||||
p.textContent = '';
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = row.dataset.tag ?? '';
|
||||
p.append(img);
|
||||
} else {
|
||||
p.innerHTML = "<em>Nessun override: il sito usa l'immagine originale.</em>";
|
||||
}
|
||||
row.querySelector<HTMLElement>('.crow-snippet')!.textContent =
|
||||
url ? '(immagine personalizzata)' : '(immagine originale)';
|
||||
}
|
||||
|
||||
async function call(row: HTMLElement, btn: HTMLButtonElement, req: () => Promise<Response>,
|
||||
onOk: (data: Record<string, string>) => void, okMsg: string, errMsg: string): Promise<void> {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await req();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) { onOk(data); showMsg(row, true, okMsg); }
|
||||
else showMsg(row, false, data.error ?? errMsg);
|
||||
} catch {
|
||||
showMsg(row, false, 'Errore di rete.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
wrap.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('button');
|
||||
if (!btn) return;
|
||||
const row = btn.closest<HTMLElement>('details.crow')!;
|
||||
const api = `/api/admin/content/${encodeURIComponent(row.dataset.tag!)}`;
|
||||
|
||||
if (btn.hasAttribute('data-save')) {
|
||||
const value = row.querySelector<HTMLInputElement | HTMLTextAreaElement>('[data-value]')!.value;
|
||||
const styles: Record<string, string> = {};
|
||||
row.querySelectorAll<HTMLSelectElement>('[data-style]').forEach((s) => {
|
||||
if (s.value) styles[s.dataset.style!] = s.value;
|
||||
});
|
||||
call(row, btn,
|
||||
() => fetch(api, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value, styles }),
|
||||
}),
|
||||
(data) => {
|
||||
const saved = typeof data.value === 'string' ? data.value : value;
|
||||
const field = row.querySelector<HTMLInputElement | HTMLTextAreaElement>('[data-value]')!;
|
||||
field.value = saved;
|
||||
row.dataset.q = `${row.dataset.tag} ${saved}`.toLowerCase();
|
||||
row.querySelector<HTMLElement>('.crow-snippet')!.textContent =
|
||||
saved.length > 60 ? `${saved.slice(0, 60)}…` : saved;
|
||||
},
|
||||
'Salvato.', 'Salvataggio non riuscito.');
|
||||
} else if (btn.hasAttribute('data-upload')) {
|
||||
const file = row.querySelector<HTMLInputElement>('[data-file]')!.files?.[0];
|
||||
if (!file) { showMsg(row, false, 'Seleziona prima un file.'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
call(row, btn,
|
||||
() => fetch(`${api}/image`, { method: 'POST', body: fd }),
|
||||
(data) => setPreview(row, data.url),
|
||||
'Immagine caricata.', 'Upload non riuscito.');
|
||||
} else if (btn.hasAttribute('data-restore')) {
|
||||
call(row, btn,
|
||||
() => fetch(`${api}/image`, { method: 'DELETE' }),
|
||||
() => setPreview(row, null),
|
||||
'Ripristinata immagine originale.', 'Ripristino non riuscito.');
|
||||
}
|
||||
});
|
||||
|
||||
// Ricerca client-side: nasconde le righe non corrispondenti (tag o contenuto).
|
||||
const search = document.getElementById('csearch') as HTMLInputElement;
|
||||
const rowEls = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.crow'));
|
||||
const groupEls = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.cgroup'));
|
||||
search.addEventListener('input', () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
for (const r of rowEls) r.hidden = q !== '' && !(r.dataset.q ?? '').includes(q);
|
||||
let any = false;
|
||||
for (const g of groupEls) {
|
||||
const visible = g.querySelector('details.crow:not([hidden])') !== null;
|
||||
g.hidden = !visible;
|
||||
if (q && visible) g.open = true;
|
||||
any = any || visible;
|
||||
}
|
||||
document.getElementById('cempty')!.hidden = any;
|
||||
});
|
||||
|
||||
// Ancore: #<tag> apre gruppo + riga, scrolla ed evidenzia.
|
||||
function openHash(): void {
|
||||
let tag: string;
|
||||
try { tag = decodeURIComponent(location.hash.slice(1)); } catch { return; }
|
||||
if (!tag) return;
|
||||
const row = document.getElementById(tag);
|
||||
if (!row || !row.classList.contains('crow')) return;
|
||||
const group = row.closest<HTMLDetailsElement>('details.cgroup');
|
||||
if (group) group.open = true;
|
||||
(row as HTMLDetailsElement).open = true;
|
||||
row.scrollIntoView({ block: 'center' });
|
||||
row.classList.add('crow--hl');
|
||||
setTimeout(() => row.classList.remove('crow--hl'), 2500);
|
||||
}
|
||||
window.addEventListener('hashchange', openHash);
|
||||
openHash();
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import PostForm from '../../../components/admin/PostForm.astro';
|
||||
import { getDb } from '../../../lib/db';
|
||||
import { getPostById, canModifyPost } from '../../../lib/posts';
|
||||
export const prerender = false;
|
||||
const post = getPostById(getDb(), Number(Astro.params.id));
|
||||
if (!post) return Astro.redirect('/admin');
|
||||
const u = Astro.locals.user!;
|
||||
if (!canModifyPost(u.role, u.id, post)) return Astro.redirect('/admin');
|
||||
---
|
||||
<Admin title={`Modifica: ${post.title}`}>
|
||||
<h1>Modifica articolo</h1>
|
||||
<script id="initial-body" type="text/plain" set:html={post.body_html}></script>
|
||||
<PostForm post={post} />
|
||||
</Admin>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listAllPosts, listByAuthor, listTrashed } from '../../lib/posts';
|
||||
import { listUsers } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
const user = Astro.locals.user!;
|
||||
const isManager = user.role === 'superuser' || user.role === 'admin';
|
||||
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
|
||||
const cestinati = listTrashed(getDb(), isManager ? undefined : user.id).length;
|
||||
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||||
---
|
||||
<Admin title="Articoli">
|
||||
<h1>Articoli</h1>
|
||||
|
||||
<p class="atoolbar">
|
||||
<a class="abtn" href="/admin/new">+ Nuovo articolo</a>
|
||||
{cestinati > 0 && <a class="abtn abtn--ghost" href="/admin/cestino">Cestino ({cestinati})</a>}
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th class="uactions">Azioni</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((p) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
|
||||
<td>{p.category}</td>
|
||||
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
|
||||
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
|
||||
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
|
||||
<td class="uactions">
|
||||
<a class="aicon" href={`/admin/edit/${p.id}`} title="Modifica" aria-label={`Modifica ${p.title}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 17 9.5-9.5 3 3L7 20H4v-3Zm11.7-11.7 1.6-1.6a1.4 1.4 0 0 1 2 0l1 1a1.4 1.4 0 0 1 0 2l-1.6 1.6-3-3Z"/></svg>
|
||||
</a>
|
||||
<button class="aicon aicon--danger" data-del={p.id} title="Sposta nel cestino" aria-label={`Sposta ${p.title} nel cestino`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{posts.length === 0 && <p class="amuted">Nessun articolo: creane uno nuovo.</p>}
|
||||
<p class="form-msg" id="row-msg"></p>
|
||||
</Admin>
|
||||
|
||||
<script>
|
||||
const rowMsg = document.getElementById('row-msg')!;
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm('Spostare questo articolo nel cestino? Sparirà dal sito, ma potrai recuperarlo.')) return;
|
||||
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
||||
if (res.ok) location.reload();
|
||||
else {
|
||||
rowMsg.className = 'form-msg form-msg--err';
|
||||
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
|
||||
}
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { login, getSessionUser, logout, landingFor, isRole, SESSION_COOKIE } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
let error = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
const form = await Astro.request.formData();
|
||||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||||
if (token) {
|
||||
const utente = getSessionUser(getDb(), token);
|
||||
// Un account delle aree riservate rimasto in questo database (i ruoli piattaforme,
|
||||
// cliente e trainer sono emigrati il 03/09/2026) non deve entrare qui.
|
||||
// Senza questo controllo la password sarebbe giusta, la sessione verrebbe creata, e
|
||||
// poi il middleware lo rimbalzerebbe su landingFor -> /admin -> di nuovo il middleware:
|
||||
// un CICLO di redirect, che sembra un guasto del sito invece di un account nel posto
|
||||
// sbagliato. La sessione appena creata va chiusa, non lasciata aperta a vuoto.
|
||||
if (!utente || !isRole(utente.role)) {
|
||||
logout(getDb(), token);
|
||||
error = 'Questo account appartiene all area riservata: si accede da apps.insanitylab.it.';
|
||||
} else {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
return Astro.redirect(landingFor(utente.role));
|
||||
}
|
||||
} else {
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
}
|
||||
---
|
||||
<Admin title="Login">
|
||||
<h1>Accesso</h1>
|
||||
{error && <p class="form-msg form-msg--err">{error}</p>}
|
||||
<form class="aform" method="post">
|
||||
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
|
||||
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||
<button class="abtn" type="submit">Entra</button>
|
||||
</form>
|
||||
</Admin>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { logout, SESSION_COOKIE } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = ({ cookies, redirect }) => {
|
||||
const token = cookies.get(SESSION_COOKIE)?.value;
|
||||
if (token) logout(getDb(), token);
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
return redirect('/');
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import PostForm from '../../components/admin/PostForm.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Admin title="Nuovo articolo">
|
||||
<h1>Nuovo articolo</h1>
|
||||
<script id="initial-body" type="text/plain"></script>
|
||||
<PostForm />
|
||||
</Admin>
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listUsers, ROLES } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
const users = listUsers(getDb());
|
||||
const me = Astro.locals.user!;
|
||||
---
|
||||
<Admin title="Utenti">
|
||||
<h1>Utenti</h1>
|
||||
|
||||
<p><a class="abtn" href="/admin/users/new">+ Nuovo utente</a></p>
|
||||
|
||||
<!-- Pannello password: si apre dall'icona a chiave della riga. Permette di scegliere una
|
||||
password o di farla generare; il risultato si vede una volta sola, perché nel database
|
||||
finisce solo il suo hash. -->
|
||||
<div class="apw" id="pwbox" hidden>
|
||||
<p class="apw__label">Password di <strong id="pwbox-user"></strong></p>
|
||||
|
||||
<div id="pwbox-form">
|
||||
<div class="apw__row">
|
||||
<input class="afield apw__input" type="text" id="pwbox-input" placeholder="Scrivi una password (min 8)" autocomplete="off" />
|
||||
<button class="abtn" type="button" id="pwbox-set">Imposta</button>
|
||||
<button class="abtn abtn--ghost" type="button" id="pwbox-gen">Genera casuale</button>
|
||||
<button class="abtn abtn--ghost" type="button" id="pwbox-close">Annulla</button>
|
||||
</div>
|
||||
<p class="apw__hint">La password attuale smetterà di funzionare.</p>
|
||||
<p class="form-msg" id="pwbox-msg" hidden></p>
|
||||
</div>
|
||||
|
||||
<div id="pwbox-result" hidden>
|
||||
<div class="apw__row">
|
||||
<code id="pwbox-value"></code>
|
||||
<button class="abtn" type="button" id="pwbox-copy">Copia</button>
|
||||
<button class="abtn abtn--ghost" type="button" id="pwbox-done">Chiudi</button>
|
||||
</div>
|
||||
<p class="apw__hint">Consegnala ora: chiuso questo riquadro non è più recuperabile.</p>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Utente</th><th>Ruolo</th><th class="uactions">Azioni</th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr data-id={u.id}>
|
||||
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||||
<td>
|
||||
<select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}>
|
||||
{ROLES.map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td class="uactions">
|
||||
<button class="aicon" data-reset={u.id} title="Genera una nuova password" aria-label={`Genera una nuova password per ${u.username}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 7a4 4 0 1 1-3.9 5H7v2H5v2H2v-3l5.1-5.1A4 4 0 0 1 14 7Z"/><circle cx="15.5" cy="9.5" r="1.2" fill="#fff"/></svg>
|
||||
</button>
|
||||
{u.id !== me.id && (
|
||||
<button class="aicon aicon--danger" data-del={u.id} title="Elimina utente" aria-label={`Elimina ${u.username}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="form-msg" id="row-msg"></p>
|
||||
</Admin>
|
||||
|
||||
<script>
|
||||
const rowMsg = document.getElementById('row-msg')!;
|
||||
const show = (el: HTMLElement, text: string, ok = false) => {
|
||||
el.textContent = text;
|
||||
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
|
||||
};
|
||||
|
||||
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
|
||||
sel.addEventListener('change', async () => {
|
||||
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
|
||||
});
|
||||
if (res.ok) { sel.dataset.role = sel.value; show(rowMsg, 'Ruolo aggiornato', true); }
|
||||
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
|
||||
}));
|
||||
|
||||
const pwbox = document.getElementById('pwbox')!;
|
||||
const pwUser = document.getElementById('pwbox-user')!;
|
||||
const pwValue = document.getElementById('pwbox-value')!;
|
||||
const pwForm = document.getElementById('pwbox-form')!;
|
||||
const pwResult = document.getElementById('pwbox-result')!;
|
||||
const pwInput = document.getElementById('pwbox-input') as HTMLInputElement;
|
||||
const pwMsg = document.getElementById('pwbox-msg')!;
|
||||
const pwCopy = document.getElementById('pwbox-copy') as HTMLButtonElement;
|
||||
let idCorrente = '';
|
||||
|
||||
const chiudi = () => { pwbox.hidden = true; };
|
||||
document.getElementById('pwbox-close')!.addEventListener('click', chiudi);
|
||||
document.getElementById('pwbox-done')!.addEventListener('click', chiudi);
|
||||
|
||||
/** Manda la nuova password: senza corpo il server ne genera una a caso. */
|
||||
async function cambiaPassword(password?: string): Promise<void> {
|
||||
const res = await fetch(`/api/admin/users/${idCorrente}/reset-password`, {
|
||||
method: 'POST',
|
||||
...(password
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }
|
||||
: {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
pwMsg.hidden = false;
|
||||
pwMsg.className = 'form-msg form-msg--err';
|
||||
pwMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
|
||||
return;
|
||||
}
|
||||
const dati = await res.json();
|
||||
pwValue.textContent = dati.password;
|
||||
pwForm.hidden = true;
|
||||
pwResult.hidden = false;
|
||||
}
|
||||
|
||||
document.getElementById('pwbox-set')!.addEventListener('click', () => {
|
||||
const scelta = pwInput.value.trim();
|
||||
if (scelta.length < 8) {
|
||||
pwMsg.hidden = false;
|
||||
pwMsg.className = 'form-msg form-msg--err';
|
||||
pwMsg.textContent = 'Password troppo corta (min 8).';
|
||||
return;
|
||||
}
|
||||
cambiaPassword(scelta);
|
||||
});
|
||||
|
||||
document.getElementById('pwbox-gen')!.addEventListener('click', () => cambiaPassword());
|
||||
|
||||
pwCopy.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(pwValue.textContent ?? '');
|
||||
pwCopy.textContent = 'Copiata';
|
||||
setTimeout(() => { pwCopy.textContent = 'Copia'; }, 1500);
|
||||
} catch {
|
||||
// Senza permesso sugli appunti resta la selezione manuale: evidenziamo il testo.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(pwValue);
|
||||
getSelection()?.removeAllRanges();
|
||||
getSelection()?.addRange(range);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
|
||||
b.addEventListener('click', () => {
|
||||
idCorrente = b.dataset.reset!;
|
||||
pwUser.textContent = (b.closest('tr')!.querySelector('td')!.textContent ?? '').replace(' (tu)', '');
|
||||
pwInput.value = '';
|
||||
pwMsg.hidden = true;
|
||||
pwResult.hidden = true;
|
||||
pwForm.hidden = false;
|
||||
pwbox.hidden = false;
|
||||
pwbox.scrollIntoView({ block: 'nearest' });
|
||||
pwInput.focus();
|
||||
}));
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm('Eliminare definitivamente questo utente?')) return;
|
||||
const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
|
||||
if (res.ok) location.reload();
|
||||
else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
// Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e
|
||||
// il modulo ha spazio per le sue spiegazioni.
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { ROLES } from '../../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina
|
||||
// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto.
|
||||
const DESCRIZIONI: Partial<Record<(typeof ROLES)[number], string>> = {
|
||||
user: 'scrive i propri articoli',
|
||||
superuser: 'articoli e contenuti del sito',
|
||||
admin: 'accesso completo',
|
||||
};
|
||||
---
|
||||
<Admin title="Nuovo utente">
|
||||
<h1>Nuovo utente</h1>
|
||||
|
||||
<form class="aform" id="new-user">
|
||||
<label class="alabel" for="username">Nome utente</label>
|
||||
<input class="afield" id="username" name="username" required autocomplete="off" />
|
||||
|
||||
<label class="alabel" for="password">Password</label>
|
||||
<input class="afield" id="password" name="password" type="text" required minlength="8" autocomplete="off" />
|
||||
<p class="ahint">Almeno 8 caratteri. Resta in chiaro qui: copiala e consegnala alla persona.</p>
|
||||
|
||||
<label class="alabel" for="role">Ruolo</label>
|
||||
<select class="afield" id="role" name="role">
|
||||
{ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
|
||||
</select>
|
||||
|
||||
<div class="aactions">
|
||||
<button class="abtn" type="submit">Crea utente</button>
|
||||
<a class="abtn abtn--ghost" href="/admin/users">Annulla</a>
|
||||
</div>
|
||||
<p class="form-msg" id="new-msg" hidden></p>
|
||||
</form>
|
||||
</Admin>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('new-user') as HTMLFormElement;
|
||||
const msg = document.getElementById('new-msg') as HTMLElement;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const dati = new FormData(form);
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: String(dati.get('username') ?? ''),
|
||||
password: String(dati.get('password') ?? ''),
|
||||
role: String(dati.get('role') ?? 'user'),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
location.href = '/admin/users';
|
||||
return;
|
||||
}
|
||||
msg.hidden = false;
|
||||
msg.className = 'form-msg form-msg--err';
|
||||
msg.textContent = (await res.json()).error ?? 'Creazione non riuscita.';
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { applyContentUpdate } from '../../../../lib/content-update';
|
||||
import { invalidateContentCache } from '../../../../lib/content';
|
||||
import { seedByTag } from '../../../../data/content-seed';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const tag = params.tag ?? '';
|
||||
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 ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
|
||||
const r = applyContentUpdate(getDb(), params.tag ?? '', data, locals.user!.username);
|
||||
if (!r.ok) return json(r.status, { error: r.error });
|
||||
invalidateContentCache();
|
||||
// Il value salvato può differire da quello inviato (sanitizeInline sui tag html):
|
||||
// lo restituiamo così il pannello mostra il dato reale.
|
||||
const row = getDb().prepare('SELECT value FROM content_blocks WHERE tag = ?').get(params.tag) as { value: string };
|
||||
return json(200, { ok: true, value: row.value });
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { getDb } from '../../../../../lib/db';
|
||||
import { getEnv } from '../../../../../lib/env';
|
||||
import { invalidateContentCache } from '../../../../../lib/content';
|
||||
import { validateUploadFile } from '../../../../../lib/upload-rules';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// Difesa in profondità: oggi i tag arrivano solo dal seed, ma il nome file deriva
|
||||
// dal tag — vincolarne il formato evita sorprese se in futuro i tag diventano dinamici.
|
||||
const TAG_RE = /^[a-z0-9][a-z0-9.-]*$/;
|
||||
|
||||
function findImageTag(tag: string): { ok: true } | { ok: false; status: number; error: string } {
|
||||
if (!TAG_RE.test(tag)) return { ok: false, status: 400, error: 'Tag non valido.' };
|
||||
const row = getDb().prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined;
|
||||
if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' };
|
||||
if (row.type !== 'image') return { ok: false, status: 400, error: 'Il tag non è di tipo immagine.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ params, request, locals }) => {
|
||||
const tag = params.tag ?? '';
|
||||
const found = findImageTag(tag);
|
||||
if (!found.ok) return json(found.status, { error: found.error });
|
||||
const form = await request.formData().catch(() => null);
|
||||
const checked = validateUploadFile(form?.get('file'));
|
||||
if (!checked.ok) return json(400, { error: checked.error });
|
||||
const dir = join(getEnv('UPLOADS_DIR', 'uploads'), 'content');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const name = `${tag.replaceAll('.', '-')}-${Date.now()}-${randomBytes(4).toString('hex')}${checked.ext}`;
|
||||
await writeFile(join(dir, name), Buffer.from(await checked.file.arrayBuffer()));
|
||||
getDb().prepare(
|
||||
`UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = ? WHERE tag = ?`
|
||||
).run(`content/${name}`, locals.user!.username, tag);
|
||||
invalidateContentCache();
|
||||
return json(200, { url: `/uploads/content/${name}` });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = ({ params, locals }) => {
|
||||
const tag = params.tag ?? '';
|
||||
const found = findImageTag(tag);
|
||||
if (!found.ok) return json(found.status, { error: found.error });
|
||||
getDb().prepare(
|
||||
`UPDATE content_blocks SET value = '', updated_at = datetime('now'), updated_by = ? WHERE tag = ?`
|
||||
).run(locals.user!.username, tag);
|
||||
invalidateContentCache();
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { seedByTag } from '../../../../data/content-seed';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const rows = getDb().prepare(
|
||||
'SELECT tag, type, value, styles, updated_at, updated_by FROM content_blocks ORDER BY tag'
|
||||
).all() as { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }[];
|
||||
const items = rows.map((r) => {
|
||||
let styles: Record<string, string> = {};
|
||||
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
|
||||
if (typeof styles !== 'object' || styles === null) styles = {};
|
||||
return {
|
||||
tag: r.tag,
|
||||
type: r.type,
|
||||
value: r.value,
|
||||
styles,
|
||||
styleOptions: seedByTag.get(r.tag)?.styleOptions ?? [],
|
||||
updatedAt: r.updated_at,
|
||||
updatedBy: r.updated_by,
|
||||
};
|
||||
});
|
||||
return json(200, { items });
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { getPostById, updatePost, deletePost, trashPost, restorePost, canModifyPost } from '../../../../lib/posts';
|
||||
import { parsePostBody } from '../../../../lib/post-body';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
const id = Number(params.id);
|
||||
const existing = getPostById(getDb(), id);
|
||||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||||
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi modificare questo articolo.' });
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const parsed = parsePostBody(data);
|
||||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||||
try {
|
||||
updatePost(getDb(), id, parsed.value);
|
||||
return json(200, { ok: true });
|
||||
} catch (err: any) {
|
||||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sposta l'articolo nel cestino. Con `?definitivo=1` lo cancella davvero: è la sola via per
|
||||
* perdere il contenuto, e vale solo per ciò che è già nel cestino.
|
||||
*/
|
||||
export const DELETE: APIRoute = ({ params, locals, url }) => {
|
||||
const id = Number(params.id);
|
||||
const existing = getPostById(getDb(), id);
|
||||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||||
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
|
||||
|
||||
if (url.searchParams.get('definitivo') === '1') {
|
||||
if (!existing.deleted_at) return json(400, { error: 'Sposta prima l\'articolo nel cestino.' });
|
||||
deletePost(getDb(), id);
|
||||
return json(200, { ok: true, definitivo: true });
|
||||
}
|
||||
|
||||
trashPost(getDb(), id);
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
|
||||
/** Riporta l'articolo fuori dal cestino, nello stato in cui era (bozza o pubblicato). */
|
||||
export const POST: APIRoute = ({ params, locals }) => {
|
||||
const id = Number(params.id);
|
||||
const existing = getPostById(getDb(), id);
|
||||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||||
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi ripristinare questo articolo.' });
|
||||
restorePost(getDb(), id);
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { createPost } from '../../../../lib/posts';
|
||||
import { parsePostBody } from '../../../../lib/post-body';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const parsed = parsePostBody(data);
|
||||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||||
try {
|
||||
const id = createPost(getDb(), { ...parsed.value, author_id: locals.user!.id });
|
||||
return json(201, { id, slug: parsed.value.slug });
|
||||
} catch (err: any) {
|
||||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
||||
const role = locals.user!.role;
|
||||
if (role !== 'superuser' && role !== 'admin') return json(403, { error: 'Permessi insufficienti' });
|
||||
let data: { on?: unknown };
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
if (data.on) {
|
||||
cookies.set('showtags', '1', { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD });
|
||||
} else {
|
||||
cookies.delete('showtags', { path: '/' });
|
||||
}
|
||||
return json(200, { on: Boolean(data.on) });
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { getEnv } from '../../../lib/env';
|
||||
import { validateUploadFile } from '../../../lib/upload-rules';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const form = await request.formData().catch(() => null);
|
||||
const checked = validateUploadFile(form?.get('file'));
|
||||
if (!checked.ok) return json(400, { error: checked.error });
|
||||
const dir = getEnv('UPLOADS_DIR', 'uploads');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const name = `${Date.now()}-${randomBytes(4).toString('hex')}${checked.ext}`;
|
||||
await writeFile(join(dir, name), Buffer.from(await checked.file.arrayBuffer()));
|
||||
return json(200, { url: `/uploads/${name}` });
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../../lib/db';
|
||||
import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const DELETE: APIRoute = ({ params, locals }) => {
|
||||
const id = Number(params.id);
|
||||
const target = getUserById(getDb(), id);
|
||||
if (!target) return json(404, { error: 'Utente inesistente.' });
|
||||
if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' });
|
||||
if (target.role === 'admin' && countAdmins(getDb()) <= 1) {
|
||||
return json(400, { error: 'Deve restare almeno un admin.' });
|
||||
}
|
||||
deleteUser(getDb(), id);
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../../lib/db';
|
||||
import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Cambia la password di un utente. Senza corpo la genera a caso; con `{ password }` usa
|
||||
* quella scelta dall'admin. In entrambi i casi la risposta la riporta in chiaro una volta
|
||||
* sola: nel database finisce solo il suo hash.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
const id = Number(params.id);
|
||||
if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
|
||||
|
||||
let scelta = '';
|
||||
if (request.headers.get('content-type')?.includes('application/json')) {
|
||||
try {
|
||||
const data = (await request.json()) as { password?: unknown };
|
||||
scelta = String(data.password ?? '');
|
||||
} catch {
|
||||
return json(400, { error: 'Richiesta non valida.' });
|
||||
}
|
||||
}
|
||||
if (scelta && scelta.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
|
||||
|
||||
const password = scelta || randomPassword();
|
||||
updateUserPassword(getDb(), id, hashPassword(password));
|
||||
return json(200, { password });
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../../lib/db';
|
||||
import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = Number(params.id);
|
||||
const target = getUserById(getDb(), id);
|
||||
if (!target) return json(404, { error: 'Utente inesistente.' });
|
||||
let data: { role?: unknown };
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
|
||||
if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) {
|
||||
return json(400, { error: 'Deve restare almeno un admin.' });
|
||||
}
|
||||
updateUserRole(getDb(), id, data.role);
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { createUser, listUsers, isRole } from '../../../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) });
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
let data: { username?: unknown; password?: unknown; role?: unknown };
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const username = String(data.username ?? '').trim();
|
||||
const password = String(data.password ?? '');
|
||||
if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' });
|
||||
if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
|
||||
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
|
||||
try {
|
||||
const id = createUser(getDb(), username, password, data.role);
|
||||
return json(201, { id });
|
||||
} catch (err: any) {
|
||||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { validateContact } from '../../lib/contact';
|
||||
import { sendContactEmail } from '../../lib/mailer';
|
||||
import { rateLimit, clientIp } from '../../lib/rate-limit';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
let data: unknown;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const result = validateContact(data);
|
||||
if (!result.ok) return json(400, { error: result.error });
|
||||
// Rate-limit solo sugli invii validi, per IP reale: bot/honeypot non
|
||||
// consumano la quota di un utente legittimo.
|
||||
if (!rateLimit(`contact:${clientIp(request, clientAddress)}`, 5, 60 * 60 * 1000)) {
|
||||
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
|
||||
}
|
||||
try {
|
||||
await sendContactEmail(result.value);
|
||||
} catch (err) {
|
||||
console.error('Invio email fallito:', err);
|
||||
return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' });
|
||||
}
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import Sidebar from '../../components/blog/Sidebar.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { getPublishedBySlug } from '../../lib/posts';
|
||||
import { getUsernameById } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
const post = getPublishedBySlug(getDb(), Astro.params.slug!);
|
||||
if (!post) return new Response(null, { status: 404 });
|
||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
|
||||
const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null;
|
||||
const byline = author ?? 'Staff InsanityLab';
|
||||
---
|
||||
<Base title={post.title} description={post.excerpt}>
|
||||
<section class="section">
|
||||
<div class="container art">
|
||||
<article>
|
||||
{post.cover && <img class="art__cover" src={post.cover} alt="" />}
|
||||
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
|
||||
<h1>{post.title}</h1>
|
||||
<div class="art__body" set:html={post.body_html} />
|
||||
</article>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.art { display: grid; grid-template-columns: 2.4fr 1fr; gap: 60px; }
|
||||
.art__cover { width: 100%; margin-bottom: 24px; }
|
||||
.art__meta { font-size: .8rem; color: var(--c-text-light); }
|
||||
.art__body :global(img) { margin-block: 20px; }
|
||||
.art__body :global(blockquote) { border-left: 3px solid var(--c-accent); margin: 24px 0; padding: 6px 0 6px 20px; font-style: italic; color: var(--c-heading); }
|
||||
@media (max-width: 991px) { .art { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listPublished, countPublishedByCategory } from '../../lib/posts';
|
||||
import { CATEGORIES } from '../../lib/blog-const';
|
||||
export const prerender = false;
|
||||
|
||||
const PER_PAGE = 9;
|
||||
const url = Astro.url;
|
||||
const category = url.searchParams.get('categoria') ?? undefined;
|
||||
const page = Math.max(1, Number(url.searchParams.get('pagina') ?? '1') || 1);
|
||||
const { items, total } = listPublished(getDb(), {
|
||||
category: category && (CATEGORIES as readonly string[]).includes(category) ? category : undefined,
|
||||
page, perPage: PER_PAGE,
|
||||
});
|
||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
// Conteggi per categoria: mostrati accanto ai filtri, così si vede subito dove c'è materiale.
|
||||
const perCategoria = countPublishedByCategory(getDb());
|
||||
const totalePubblicati = Object.values(perCategoria).reduce((n, v) => n + v, 0);
|
||||
const fmt = (d: string | null) => d ? new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(d)) : '';
|
||||
const linkFor = (cat?: string, p = 1) => {
|
||||
const q = new URLSearchParams();
|
||||
if (cat) q.set('categoria', cat);
|
||||
if (p > 1) q.set('pagina', String(p));
|
||||
const s = q.toString();
|
||||
return `/blog${s ? `?${s}` : ''}`;
|
||||
};
|
||||
---
|
||||
<Base title="Blog & Edugo" description="Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute.">
|
||||
<PageHero title="Blog & Edugo" />
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<h2>Blog & Edugo</h2>
|
||||
<p>Edugo è lo spazio di InsanityLab dedicato alla conoscenza: articoli, guide e approfondimenti su allenamento, nutrizione e salute per capire il corpo e migliorare il proprio benessere. Perché la conoscenza è il primo passo del cambiamento.</p>
|
||||
</div>
|
||||
<nav class="bfilters" aria-label="categorie">
|
||||
<a class:list={['bfilters__item', { 'is-active': !category }]} href={linkFor()}>
|
||||
Mostra tutto <span class="bfilters__n">({totalePubblicati})</span>
|
||||
</a>
|
||||
{CATEGORIES.map((c) => (
|
||||
<a class:list={['bfilters__item', { 'is-active': category === c }]} href={linkFor(c)}>
|
||||
{c} <span class="bfilters__n">({perCategoria[c] ?? 0})</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div id="blog-results">
|
||||
{items.length === 0 ? (
|
||||
<p class="bempty">Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.</p>
|
||||
) : (
|
||||
<div class="bgrid">
|
||||
{items.map((post) => (
|
||||
<article class="bcard">
|
||||
{post.cover && <a href={`/blog/${post.slug}`}><img src={post.cover} alt="" loading="lazy" /></a>}
|
||||
<p class="bcard__date">{fmt(post.published_at)}</p>
|
||||
<h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
|
||||
<p>{post.excerpt}</p>
|
||||
<a class="bcard__more" href={`/blog/${post.slug}`}>Per saperne di più →</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pages > 1 && (
|
||||
<nav class="bpages" aria-label="paginazione">
|
||||
{Array.from({ length: pages }, (_, i) => (
|
||||
<a class:list={['bpages__n', { 'is-active': page === i + 1 }]} href={linkFor(category, i + 1)}>{i + 1}</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
// Cambio categoria e pagina senza ricaricare: si scarica la stessa pagina e si sostituiscono
|
||||
// solo la griglia e i filtri. I link restano normali, quindi senza JS tutto continua a
|
||||
// funzionare come prima.
|
||||
const risultati = document.getElementById('blog-results');
|
||||
const filtri = document.querySelector('.bfilters');
|
||||
|
||||
if (risultati && filtri) {
|
||||
let inCorso: AbortController | null = null;
|
||||
|
||||
async function mostra(href: string, scorri: boolean): Promise<void> {
|
||||
inCorso?.abort();
|
||||
const controller = new AbortController();
|
||||
inCorso = controller;
|
||||
risultati!.classList.add('is-loading');
|
||||
try {
|
||||
const res = await fetch(href, { signal: controller.signal });
|
||||
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
||||
const nuoviRisultati = doc.getElementById('blog-results');
|
||||
const nuoviFiltri = doc.querySelector('.bfilters');
|
||||
// Se la pagina non ha la forma attesa (errore, redirect) si lascia fare al browser.
|
||||
if (!nuoviRisultati || !nuoviFiltri) { location.href = href; return; }
|
||||
risultati!.innerHTML = nuoviRisultati.innerHTML;
|
||||
filtri!.innerHTML = nuoviFiltri.innerHTML;
|
||||
if (scorri) filtri!.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} catch (e) {
|
||||
if ((e as Error).name === 'AbortError') return;
|
||||
location.href = href;
|
||||
return;
|
||||
} finally {
|
||||
if (inCorso === controller) {
|
||||
risultati!.classList.remove('is-loading');
|
||||
inCorso = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delega sul documento: i link vengono rigenerati a ogni cambio, così non serve riagganciarli.
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = (e.target as Element).closest<HTMLAnchorElement>('.bfilters__item, .bpages__n');
|
||||
if (!link || e.metaKey || e.ctrlKey || e.shiftKey || (e as MouseEvent).button !== 0) return;
|
||||
e.preventDefault();
|
||||
if (link.classList.contains('is-active')) return;
|
||||
history.pushState({}, '', link.href);
|
||||
mostra(link.href, link.classList.contains('bpages__n'));
|
||||
});
|
||||
|
||||
// Avanti/indietro del browser: si ricarica il contenuto senza toccare la cronologia.
|
||||
addEventListener('popstate', () => mostra(location.href, false));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bfilters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 50px; }
|
||||
.bfilters__item { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; text-decoration: none; color: var(--c-text); padding: 10px 16px; }
|
||||
.bfilters__item.is-active { background: var(--c-accent); color: #fff; }
|
||||
.bfilters__n { color: var(--c-text-light); }
|
||||
.bfilters__item.is-active .bfilters__n { color: rgba(255,255,255,.75); }
|
||||
.bfilters__item { transition: background .2s ease, color .2s ease; }
|
||||
#blog-results { transition: opacity .25s ease; }
|
||||
#blog-results.is-loading { opacity: .35; }
|
||||
.bempty { text-align: center; }
|
||||
.bgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; }
|
||||
.bcard img { aspect-ratio: 4/3; object-fit: cover; width: 100%; margin-bottom: 14px; }
|
||||
.bcard__date { font-size: .78rem; color: var(--c-text-light); margin-bottom: 4px; }
|
||||
.bcard h3 a { text-decoration: none; }
|
||||
.bcard__more { font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||||
.bpages { display: flex; gap: 8px; justify-content: center; margin-top: 50px; }
|
||||
.bpages__n { font-family: var(--font-heading); text-decoration: none; padding: 8px 14px; color: var(--c-text); }
|
||||
.bpages__n.is-active { background: var(--c-dark); color: #fff; }
|
||||
@media (max-width: 991px) { .bgrid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 600px) { .bgrid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari';
|
||||
import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Il PDF degli orari, come file scaricabile.
|
||||
//
|
||||
// /calendario/tutti.pdf tutti gli orari validi, uno per pagina
|
||||
// /calendario/settembre-1-18.pdf un orario solo
|
||||
//
|
||||
// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file
|
||||
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica
|
||||
// coi clienti.
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
// "tutti" segue la stessa regola della pagina: gli orari scaduti non ci sono. Un PDF che si
|
||||
// porta dietro il periodo finito farebbe cercare lezioni che non esistono più.
|
||||
const cals = params.id === 'tutti'
|
||||
? calendariDaMostrare(new Date()).map((m) => m.cal)
|
||||
: calendari.filter((c) => c.id === params.id);
|
||||
|
||||
// Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa
|
||||
// credere che il calendario sia vuoto.
|
||||
if (cals.length === 0) return new Response('Calendario inesistente.', { status: 404 });
|
||||
|
||||
// Le giornate fuori griglia stanno anche nel PDF: è il file che gira su WhatsApp, e chi lo
|
||||
// riceve non ha davanti la pagina dove sono scritte.
|
||||
const pdf = await generaCalendariPdf(cals, avvisiAttivi(new Date()));
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
|
||||
// scheda e su telefono resterebbe lì, senza finire fra i file.
|
||||
'Content-Disposition': `attachment; filename="${nomeFilePdf(cals)}"`,
|
||||
'Content-Length': String(pdf.length),
|
||||
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
|
||||
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
import ContactForm from '../components/ContactForm.astro';
|
||||
import contactHero from '../assets/img/contact-hero.jpg';
|
||||
import T from '../components/content/T.astro';
|
||||
import { t, getImageOverride } from '../lib/content';
|
||||
export const prerender = false;
|
||||
const mapsQuery = encodeURIComponent(t('global.contact.map-query'));
|
||||
---
|
||||
<Base title={t('contact.meta.title')} description={t('contact.meta.description')}>
|
||||
<PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} />
|
||||
<section class="section">
|
||||
<div class="container cgrid">
|
||||
<div class="cmap" id="map-box" data-maps-query={mapsQuery}>
|
||||
<div class="cmap__consent">
|
||||
<p class="cmap__title">Mappa Google</p>
|
||||
<p class="cmap__note">Caricando la mappa accetti che Google riceva i tuoi dati di connessione (incluso l'indirizzo IP) secondo la sua informativa. Vedi la nostra <a href="/cookie-policy">Cookie Policy</a>.</p>
|
||||
<button class="btn btn--dark" id="map-load" type="button">Carica la mappa</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContactForm variant="full" />
|
||||
</div>
|
||||
<div class="container cinfo">
|
||||
<div>
|
||||
<T tag="contact.info.address-label" as="h3" />
|
||||
<p>{t('global.contact.address')} {t('global.contact.city-line')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<T tag="contact.info.phone-label" as="h3" />
|
||||
<p>{t('global.contact.phone')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<T tag="contact.info.email-label" as="h3" />
|
||||
<p><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<T tag="contact.invite" as="p" class="cinvite" />
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
|
||||
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; align-items: center; justify-content: center; }
|
||||
.cmap iframe { display: block; width: 100%; height: 100%; min-height: 420px; border: 0; }
|
||||
.cmap__consent { max-width: 380px; text-align: center; padding: 24px; display: flex; flex-direction: column; gap: 12px; align-items: center; }
|
||||
.cmap__title { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .12em; font-size: .9rem; margin: 0; }
|
||||
.cmap__note { font-size: .8rem; color: var(--c-text-light); margin: 0; line-height: 1.5; }
|
||||
.cmap__note a { color: var(--c-accent-dark); }
|
||||
.cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
|
||||
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
|
||||
.cinvite { max-width: 640px; }
|
||||
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// GDPR: la mappa Google (terza parte) viene caricata solo dopo consenso esplicito.
|
||||
document.getElementById('map-load')?.addEventListener('click', () => {
|
||||
const box = document.getElementById('map-box');
|
||||
if (!box) return;
|
||||
const q = box.dataset.mapsQuery ?? '';
|
||||
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${q}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade" style="display:block;width:100%;height:100%;min-height:420px;border:0"></iframe>`;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Cookie Policy — InsanityLab" description="Informativa sull'uso dei cookie del sito insanitylab.it.">
|
||||
<PageHero title="Cookie Policy" />
|
||||
<section class="section">
|
||||
<div class="container legal">
|
||||
<p class="legal__updated">Ultimo aggiornamento: luglio 2026</p>
|
||||
|
||||
<p>La presente Cookie Policy descrive i cookie e le tecnologie analoghe utilizzati dal sito <strong>insanitylab.it</strong>, gestito da <strong>IN-SANITY LAB S.R.L.</strong> (Via Leandro Alberti 76, 40139 Bologna — P. IVA 03927341200).</p>
|
||||
|
||||
<h2>1. Cosa sono i cookie</h2>
|
||||
<p>I cookie sono piccoli file di testo che i siti visitati inviano al dispositivo dell'utente, dove vengono memorizzati per essere ritrasmessi agli stessi siti alla visita successiva. Possono avere finalità tecniche, funzionali o di profilazione.</p>
|
||||
|
||||
<h2>2. Cookie utilizzati da questo sito</h2>
|
||||
<p>Il sito utilizza <strong>esclusivamente cookie tecnici</strong>, necessari al funzionamento dell'area riservata. Questi cookie non richiedono il consenso dell'utente e non vengono impostati ai visitatori non autenticati.</p>
|
||||
<div class="legal__table">
|
||||
<table>
|
||||
<thead><tr><th>Cookie</th><th>Finalità</th><th>Durata</th><th>Tipo</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>session</td><td>Mantiene l'autenticazione dell'utente nell'area riservata</td><td>Sessione</td><td>Tecnico</td></tr>
|
||||
<tr><td>showtags</td><td>Ricorda la preferenza di visualizzazione dei tag di modifica dei contenuti (solo utenti autorizzati)</td><td>Sessione</td><td>Tecnico / funzionale</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>Il sito <strong>non utilizza cookie di profilazione, di marketing o di analisi statistica</strong> (nessun Google Analytics o strumento equivalente).</p>
|
||||
|
||||
<h2>3. Contenuti di terze parti — Google Maps</h2>
|
||||
<p>La pagina Contatti può mostrare una mappa fornita da <strong>Google Maps</strong>. Per tutelare la tua privacy, la mappa <strong>non viene caricata automaticamente</strong>: compare solo dopo che hai cliccato l'apposito pulsante di consenso. Fino a quel momento nessun dato viene inviato a Google. Se scegli di caricare la mappa, Google può impostare propri cookie e trattare dati (incluso l'indirizzo IP) secondo la propria <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">informativa privacy</a>.</p>
|
||||
|
||||
<h2>4. Gestione dei cookie dal browser</h2>
|
||||
<p>L'utente può gestire o eliminare i cookie tramite le impostazioni del proprio browser. La disabilitazione dei cookie tecnici può compromettere il funzionamento dell'area riservata. Le istruzioni sono disponibili nelle guide dei principali browser (Chrome, Firefox, Safari, Edge).</p>
|
||||
|
||||
<h2>5. Titolare e contatti</h2>
|
||||
<p>Per qualsiasi richiesta relativa ai cookie e al trattamento dei dati è possibile scrivere a <a href="mailto:info@insanitylab.it">info@insanitylab.it</a>. Per maggiori informazioni sul trattamento dei dati personali si rimanda alla <a href="/privacy">Privacy Policy</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal { max-width: 760px; }
|
||||
.legal__updated { font-size: .8rem; color: var(--c-text-light); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 30px; }
|
||||
.legal h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: .08em; margin: 34px 0 12px; }
|
||||
.legal p, .legal li { line-height: 1.7; }
|
||||
.legal a { color: var(--c-accent-dark); }
|
||||
.legal__table { overflow-x: auto; margin: 12px 0; }
|
||||
.legal table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.legal th, .legal td { border: 1px solid var(--c-border, #e4ded6); padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
.legal th { background: var(--c-bg-alt); text-transform: uppercase; font-size: .75rem; letter-spacing: .06em; }
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import HeroSlider from '../components/home/HeroSlider.astro';
|
||||
import InfoColumns from '../components/home/InfoColumns.astro';
|
||||
import FeatureBlocks from '../components/home/FeatureBlocks.astro';
|
||||
import QuoteBanner from '../components/home/QuoteBanner.astro';
|
||||
import TrainingCards from '../components/home/TrainingCards.astro';
|
||||
import ServicesGrid from '../components/home/ServicesGrid.astro';
|
||||
import MethodSteps from '../components/home/MethodSteps.astro';
|
||||
import PartnersStrip from '../components/home/PartnersStrip.astro';
|
||||
import T from '../components/content/T.astro';
|
||||
import { t } from '../lib/content';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title={t('home.meta.title')}>
|
||||
<HeroSlider />
|
||||
<InfoColumns />
|
||||
<FeatureBlocks />
|
||||
<QuoteBanner />
|
||||
<section class="section" style="padding-bottom:0">
|
||||
<div class="section-heading">
|
||||
<T tag="home.training.title" as="h2" />
|
||||
<T tag="home.training.subtitle" as="p" />
|
||||
</div>
|
||||
</section>
|
||||
<TrainingCards />
|
||||
<ServicesGrid />
|
||||
<MethodSteps />
|
||||
<PartnersStrip />
|
||||
</Base>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Privacy Policy — InsanityLab" description="Informativa sul trattamento dei dati personali ai sensi del Regolamento (UE) 2016/679 (GDPR).">
|
||||
<PageHero title="Privacy Policy" />
|
||||
<section class="section">
|
||||
<div class="container legal">
|
||||
<p class="legal__updated">Ultimo aggiornamento: luglio 2026</p>
|
||||
|
||||
<p>La presente informativa descrive le modalità di trattamento dei dati personali degli utenti che consultano il sito <strong>insanitylab.it</strong> e che utilizzano il modulo di contatto, ai sensi dell'articolo 13 del Regolamento (UE) 2016/679 (di seguito "GDPR").</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>
|
||||
<strong>IN-SANITY LAB S.R.L.</strong><br>
|
||||
Via Leandro Alberti 76, 40139 Bologna (BO)<br>
|
||||
P. IVA e Codice Fiscale: 03927341200 — REA: BO-556474<br>
|
||||
E-mail: <a href="mailto:info@insanitylab.it">info@insanitylab.it</a> — PEC: insanitylab@pec.it<br>
|
||||
Telefono: 351 7535977
|
||||
</p>
|
||||
|
||||
<h2>2. Tipologie di dati trattati</h2>
|
||||
<p>Il sito tratta le seguenti categorie di dati:</p>
|
||||
<ul>
|
||||
<li><strong>Dati forniti volontariamente tramite il modulo di contatto</strong>: nome, cognome, indirizzo e-mail, numero di telefono (facoltativo) e il testo del messaggio.</li>
|
||||
<li><strong>Dati di navigazione</strong>: dati tecnici trasmessi automaticamente dal browser (ad esempio indirizzo IP e log del server), trattati per il funzionamento e la sicurezza del sito.</li>
|
||||
</ul>
|
||||
<p>Il sito <strong>non utilizza strumenti di analisi statistica o di profilazione</strong> (es. Google Analytics) e non installa cookie di marketing.</p>
|
||||
|
||||
<h2>3. Finalità e base giuridica</h2>
|
||||
<ul>
|
||||
<li><strong>Riscontro alle richieste inviate tramite il modulo di contatto</strong> — base giuridica: esecuzione di misure precontrattuali richieste dall'interessato e legittimo interesse del Titolare a rispondere (art. 6.1 lett. b e f GDPR).</li>
|
||||
<li><strong>Funzionamento e sicurezza del sito</strong> — base giuridica: legittimo interesse del Titolare (art. 6.1 lett. f GDPR).</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Modalità e periodo di conservazione</h2>
|
||||
<p>I dati sono trattati con strumenti elettronici e con misure di sicurezza adeguate. I messaggi ricevuti tramite il modulo di contatto vengono conservati per il tempo necessario a gestire la richiesta e a dare seguito agli eventuali rapporti che ne derivano, e comunque non oltre i termini previsti dalla normativa applicabile. I log tecnici del server sono conservati per il tempo strettamente necessario alle finalità di sicurezza.</p>
|
||||
|
||||
<h2>5. Destinatari e responsabili del trattamento</h2>
|
||||
<p>I dati possono essere trattati da soggetti che forniscono servizi al Titolare in qualità di responsabili del trattamento, in particolare:</p>
|
||||
<ul>
|
||||
<li><strong>Aruba S.p.A.</strong> — fornitore dei servizi di posta elettronica utilizzati per l'invio e la ricezione dei messaggi del modulo di contatto.</li>
|
||||
<li>Il fornitore di hosting che ospita il sito.</li>
|
||||
</ul>
|
||||
<p>I dati non sono diffusi né ceduti a terzi per finalità diverse da quelle indicate.</p>
|
||||
|
||||
<h2>6. Servizi di terze parti e trasferimenti extra UE</h2>
|
||||
<p>La pagina Contatti può visualizzare una mappa fornita da <strong>Google Maps</strong> (Google Ireland Ltd. / Google LLC). La mappa viene caricata <strong>solo previo consenso esplicito</strong> dell'utente, tramite apposito pulsante: fino a quel momento nessun dato viene trasmesso a Google. In caso di caricamento, Google può trattare dati (incluso l'indirizzo IP), anche verso Paesi terzi, secondo la propria informativa privacy. Maggiori dettagli nella <a href="/cookie-policy">Cookie Policy</a>.</p>
|
||||
|
||||
<h2>7. Diritti dell'interessato</h2>
|
||||
<p>L'interessato può in qualsiasi momento esercitare i diritti previsti dagli articoli da 15 a 22 del GDPR: accesso, rettifica, cancellazione, limitazione, portabilità, opposizione al trattamento e revoca del consenso (ove applicabile). Le richieste possono essere inviate a <a href="mailto:info@insanitylab.it">info@insanitylab.it</a>. L'interessato ha inoltre il diritto di proporre reclamo all'Autorità Garante per la protezione dei dati personali (<a href="https://www.garanteprivacy.it" target="_blank" rel="noopener">www.garanteprivacy.it</a>).</p>
|
||||
|
||||
<h2>8. Cookie</h2>
|
||||
<p>Il sito utilizza esclusivamente cookie tecnici necessari al funzionamento dell'area riservata. Per il dettaglio si rimanda alla <a href="/cookie-policy">Cookie Policy</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal { max-width: 760px; }
|
||||
.legal__updated { font-size: .8rem; color: var(--c-text-light); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 30px; }
|
||||
.legal h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: .08em; margin: 34px 0 12px; }
|
||||
.legal p, .legal li { line-height: 1.7; }
|
||||
.legal ul { padding-left: 20px; display: grid; gap: 8px; margin-bottom: 8px; }
|
||||
.legal a { color: var(--c-accent-dark); }
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
export const prerender = false;
|
||||
|
||||
// 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, '')}`;
|
||||
|
||||
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: sabato 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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
/* 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>
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import T from '../../components/content/T.astro';
|
||||
import { t } from '../../lib/content';
|
||||
import { edizioneAttiva } from '../../data/promo-edizioni';
|
||||
export const prerender = false;
|
||||
|
||||
// Chi entra da «Promo ed eventi» sceglie: le promozioni in corso oppure gli eventi del
|
||||
// centro (chiesto dal cliente il 17/08/2026). Erano la stessa pagina, e chi cercava
|
||||
// l'inaugurazione finiva in mezzo ai prezzi dei pacchetti.
|
||||
//
|
||||
// Il bottone delle promo dice se ce n'è davvero una in corso: mandare su una pagina che
|
||||
// risponde «nessuna promozione» senza averlo detto prima è un giro a vuoto.
|
||||
const ed = edizioneAttiva(new Date());
|
||||
// La pagina cambia contenuto col passare del tempo (l'edizione promo scade da sola): una
|
||||
// copia in cache la congelerebbe a com'era.
|
||||
Astro.response.headers.set('Cache-Control', 'no-store');
|
||||
---
|
||||
<Base title={t('promo-eventi.meta.title')} description={t('promo-eventi.meta.description')}>
|
||||
<PageHero title={t('promo-eventi.hero.title')} />
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="promo-eventi.intro" as="p" />
|
||||
</div>
|
||||
<div class="scelta">
|
||||
<a class="scelta__card" href="/promo-eventi/promo">
|
||||
<T tag="promo-eventi.promo.label" as="h2" />
|
||||
<T tag="promo-eventi.promo.desc" as="p" />
|
||||
{!ed && <T tag="promo-eventi.promo.nessuna" as="p" class="scelta__nota" />}
|
||||
<span class="btn">{t('promo-eventi.promo.cta')}</span>
|
||||
</a>
|
||||
<a class="scelta__card scelta__card--dark" href="/promo-eventi/eventi">
|
||||
<T tag="promo-eventi.eventi.label" as="h2" />
|
||||
<T tag="promo-eventi.eventi.desc" as="p" />
|
||||
<span class="btn btn--light">{t('promo-eventi.eventi.cta')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.scelta { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||
.scelta__card { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; padding: 48px 40px; background: #fff; box-shadow: 0 6px 24px rgba(0,0,0,.08); text-decoration: none; transition: transform .3s ease, box-shadow .3s ease; }
|
||||
.scelta__card:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(0,0,0,.12); }
|
||||
.scelta__card :global(h2) { text-transform: uppercase; letter-spacing: .12em; font-size: 1.4rem; margin: 0; }
|
||||
.scelta__card :global(p) { font-size: .92rem; margin: 0; }
|
||||
.scelta__card .btn { margin-top: auto; }
|
||||
.scelta__card--dark { background: var(--c-dark); }
|
||||
.scelta__card--dark :global(h2), .scelta__card--dark :global(p) { color: #efe9e3; }
|
||||
.scelta__nota { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; color: var(--c-accent-dark); }
|
||||
@media (max-width: 767px) { .scelta { grid-template-columns: 1fr; } .scelta__card { padding: 34px 26px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import T from '../../components/content/T.astro';
|
||||
import { t, resolveContent } from '../../lib/content';
|
||||
import { edizioneAttiva, tagEdizione } from '../../data/promo-edizioni';
|
||||
import { getSessionUser, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { getDb } from '../../lib/db';
|
||||
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
|
||||
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account e sceglie lì
|
||||
// dentro il prodotto, poi paga via Stripe. Il link è uno solo per tutte le card, ed è voluto:
|
||||
// il catalogo lo presenta bsport dopo l'accesso.
|
||||
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
|
||||
export const prerender = false;
|
||||
|
||||
// Quale promozione è in vetrina lo decide il calendario, non un deploy: l'edizione attiva è
|
||||
// quella la cui finestra contiene questo istante (vedi `data/promo-edizioni.ts`). Alla
|
||||
// scadenza dell'una subentra l'altra da sola, e quando non ce n'è nessuna la pagina lo dice
|
||||
// invece di lasciare online i prezzi di un'offerta finita.
|
||||
//
|
||||
// Anteprima: `?anteprima=2026-08-20` mostra l'edizione di quella data. Solo per chi è loggato
|
||||
// — un visitatore non deve poter vedere prezzi non ancora validi, o non più.
|
||||
const param = Astro.url.searchParams.get('anteprima');
|
||||
const token = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
const utente = token ? getSessionUser(getDb(), token) : null;
|
||||
// Una data secca (`2026-08-20`) sarebbe letta come UTC: la si porta a mezzogiorno italiano,
|
||||
// lontano dai confini di mezzanotte dove sta la differenza fra un'edizione e l'altra.
|
||||
const quandoParam = param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? `${param}T12:00:00+02:00` : param;
|
||||
const dataLeggibile = Boolean(quandoParam && !Number.isNaN(Date.parse(quandoParam)));
|
||||
const anteprima = Boolean(utente && dataLeggibile);
|
||||
// Un'anteprima chiesta e non applicata va **detta**: altrimenti la pagina risponde con
|
||||
// l'edizione di oggi e due date diverse danno lo stesso risultato — che si legge come «la
|
||||
// funzione non funziona» invece che «non sei entrato».
|
||||
const anteprimaIgnorata = !param || anteprima ? null : (!utente ? 'login' : 'data');
|
||||
const quando = anteprima ? new Date(quandoParam!) : new Date();
|
||||
const ed = edizioneAttiva(quando);
|
||||
// La pagina cambia contenuto col passare del tempo: una copia in cache la congelerebbe alla
|
||||
// vecchia edizione, e lo scambio avverrebbe quando pare al proxy invece che a mezzanotte.
|
||||
Astro.response.headers.set('Cache-Control', 'no-store');
|
||||
|
||||
// Tag dell'edizione attiva: `tg('card.1.price')` → `promo.card.1.price` o `bundle.card.1.price`.
|
||||
const tg = (suffisso: string) => (ed ? tagEdizione(ed, suffisso) : suffisso);
|
||||
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
|
||||
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
|
||||
const wa = t('global.whatsapp');
|
||||
const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`;
|
||||
// Scadenza dell'edizione in corso (tag editabile dal pannello, ISO con offset). Viene solo
|
||||
// passata al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto
|
||||
// col tempo del visitatore.
|
||||
const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
|
||||
---
|
||||
<Base title="Promo ed eventi — InsanityLab" description={ed ? t(tg('hero.desc')).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : 'Le promozioni di apertura sono terminate: scrivici su WhatsApp per i percorsi attivi.'}>
|
||||
<div class="lp">
|
||||
<div class="page">
|
||||
|
||||
{anteprima && (
|
||||
<div class="anteprima">
|
||||
Anteprima al {quando.toLocaleString('it-IT', { timeZone: 'Europe/Rome' })} —
|
||||
{ed ? ` edizione «${ed.id}»` : ' nessuna edizione attiva'}
|
||||
</div>
|
||||
)}
|
||||
{anteprimaIgnorata === 'login' && (
|
||||
<div class="anteprima ignorata">
|
||||
Anteprima non applicata: questa è la pagina di oggi. Entra da <a href="/admin/login">/admin/login</a> e ricarica.
|
||||
</div>
|
||||
)}
|
||||
{anteprimaIgnorata === 'data' && (
|
||||
<div class="anteprima ignorata">
|
||||
Anteprima non applicata: «{param}» non è una data leggibile. Usa il formato 2026-08-25.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ed ? (
|
||||
<Fragment>
|
||||
<section class="hero">
|
||||
<T tag={tg('hero.badge')} as="div" class="badge" />
|
||||
<T tag={tg('hero.title1')} as="h1" />
|
||||
<T tag={tg('hero.accent')} as="p" class="accent" />
|
||||
<T tag={tg('hero.title2')} as="h1" />
|
||||
<T tag={tg('hero.desc')} as="p" class="desc" />
|
||||
<a class="btn-sand" href="#proposte"><T tag={tg('hero.cta')} as="span" /></a>
|
||||
</section>
|
||||
|
||||
{ed.launch && (
|
||||
<Fragment>
|
||||
<section class="launch">
|
||||
<T tag={tg('launch.badge')} as="div" class="badge" />
|
||||
<T tag={tg('launch.title')} as="h1" />
|
||||
<T tag={tg('launch.accent')} as="p" class="accent" />
|
||||
<T tag={tg('launch.desc')} as="p" class="desc" />
|
||||
</section>
|
||||
|
||||
<section class="why5">
|
||||
<T tag={tg('why5.text')} as="p" />
|
||||
</section>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<section class="cards-wrap" id="proposte">
|
||||
<div class="countdown" data-deadline={countdownDeadline} hidden>
|
||||
<T tag={tg('countdown.label')} as="div" class="cd-label" />
|
||||
<div class="cd-boxes">
|
||||
<div class="cd-box"><span class="cd-num" data-cd="days">–</span><T tag={tg('countdown.unit.days')} as="span" class="cd-unit" /></div>
|
||||
<div class="cd-box"><span class="cd-num" data-cd="hours">–</span><T tag={tg('countdown.unit.hours')} as="span" class="cd-unit" /></div>
|
||||
<div class="cd-box"><span class="cd-num" data-cd="minutes">–</span><T tag={tg('countdown.unit.minutes')} as="span" class="cd-unit" /></div>
|
||||
<div class="cd-box"><span class="cd-num" data-cd="seconds">–</span><T tag={tg('countdown.unit.seconds')} as="span" class="cd-unit" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class:list={['cards', `cards-${ed.cards.length}`]}>
|
||||
{ed.cards.map((c) => (
|
||||
<div class:list={['card', c.featured && 'featured']}>
|
||||
{c.featured && <T tag={tg(`card.${c.id}.badge`)} as="div" class="featured-badge" />}
|
||||
<T tag={tg(`card.${c.id}.eyebrow`)} as="div" class="eyebrow" />
|
||||
<T tag={tg(`card.${c.id}.title`)} as="div" class="title" />
|
||||
{c.question && <T tag={tg(`card.${c.id}.question`)} as="p" class="question" />}
|
||||
<div class="price-row">
|
||||
<T tag={tg(`card.${c.id}.price`)} as="div" class="price" />
|
||||
{c.priceOld && <T tag={tg(`card.${c.id}.price-old`)} as="div" class="price-old" />}
|
||||
</div>
|
||||
{c.unit && <T tag={tg(`card.${c.id}.unit`)} as="div" class="unit" />}
|
||||
<ul>
|
||||
{Array.from({ length: c.features }, (_, i) => i + 1).map((m) => (
|
||||
<T tag={tg(`card.${c.id}.feature.${m}`)} as="li" />
|
||||
))}
|
||||
</ul>
|
||||
{c.desc && <T tag={tg(`card.${c.id}.desc`)} as="p" class="card-desc" />}
|
||||
{c.note && (
|
||||
<div class="note-box">
|
||||
<T tag={tg(`card.${c.id}.note-label`)} as="div" class="note-label" />
|
||||
<T tag={tg(`card.${c.id}.note`)} as="div" class="note-text" />
|
||||
</div>
|
||||
)}
|
||||
<a class="buy" href={BSPORT_URL} target="_blank" rel="noopener"><T tag={tg('buy-label')} as="span" /></a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<T tag={tg('validity-note')} as="p" class="validity-note" />
|
||||
</section>
|
||||
|
||||
<section class="faq-wrap">
|
||||
<T tag={tg('faq.title')} as="h1" />
|
||||
{ed.faq.map((n) => {
|
||||
const a = resolveContent(tg(`faq.${n}.a`));
|
||||
return (
|
||||
<div class="faq-block">
|
||||
<div class="faq-q"><T tag={tg(`faq.${n}.q`)} as="p" /><div class="faq-toggle">+</div></div>
|
||||
<p class:list={['faq-a', a.classes]} data-tag={tg(`faq.${n}.a`)} data-guid={a.guid || undefined} set:html={a.value.replaceAll('{{whatsapp}}', wa)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</Fragment>
|
||||
) : (
|
||||
<section class="hero chiusa">
|
||||
<T tag="promo.chiusa.title" as="h1" />
|
||||
<T tag="promo.chiusa.desc" as="p" class="desc" />
|
||||
<a class="btn-sand" href={waLink} target="_blank" rel="noopener"><T tag="promo.chiusa.cta" as="span" /></a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
// Countdown alla scadenza della promo. Parte nascosto (attributo `hidden` nel markup) e viene
|
||||
// mostrato solo se la deadline è una data valida ancora futura: così, a promo finita, il blocco
|
||||
// semplicemente non compare — nessun messaggio, nessun salto di layout.
|
||||
const cd = document.querySelector<HTMLElement>('.lp .countdown');
|
||||
if (cd) {
|
||||
const end = Date.parse(cd.dataset.deadline || '');
|
||||
const nums = new Map<string, HTMLElement>();
|
||||
cd.querySelectorAll<HTMLElement>('.cd-num').forEach((n) => nums.set(n.dataset.cd!, n));
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
// Dichiarato prima di `tick` perché la prima chiamata è sincrona: se la promo è già scaduta
|
||||
// il timer va fermato subito, e leggerlo dopo il `setInterval` darebbe un errore di TDZ.
|
||||
let timer = 0;
|
||||
const tick = () => {
|
||||
const left = end - Date.now();
|
||||
if (Number.isNaN(end) || left <= 0) { cd.hidden = true; clearInterval(timer); return; }
|
||||
const s = Math.floor(left / 1000);
|
||||
nums.get('days')!.textContent = String(Math.floor(s / 86400));
|
||||
nums.get('hours')!.textContent = pad(Math.floor(s / 3600) % 24);
|
||||
nums.get('minutes')!.textContent = pad(Math.floor(s / 60) % 60);
|
||||
nums.get('seconds')!.textContent = pad(s % 60);
|
||||
cd.hidden = false;
|
||||
};
|
||||
tick();
|
||||
timer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// Accordion FAQ: click (o Invio/Spazio) sul quesito apre/chiude la risposta.
|
||||
document.querySelectorAll('.lp .faq-block .faq-q').forEach((q) => {
|
||||
const block = q.closest('.faq-block');
|
||||
const toggle = q.querySelector('.faq-toggle');
|
||||
q.setAttribute('role', 'button');
|
||||
q.setAttribute('tabindex', '0');
|
||||
q.setAttribute('aria-expanded', 'false');
|
||||
const set = (open: boolean) => {
|
||||
block?.classList.toggle('is-open', open);
|
||||
q.setAttribute('aria-expanded', String(open));
|
||||
if (toggle) toggle.textContent = open ? '−' : '+';
|
||||
};
|
||||
q.addEventListener('click', () => set(!block?.classList.contains('is-open')));
|
||||
q.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!block?.classList.contains('is-open')); }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- is:global perché i testi editabili sono renderizzati dal componente <T> (fuori dallo
|
||||
scope della pagina): con lo scope Astro il CSS non li raggiungerebbe. Ogni regola è
|
||||
prefissata `.lp`, quindi resta confinata a questa landing senza toccare il resto del sito. -->
|
||||
<style is:global>
|
||||
.lp { background: #ECE7DC; }
|
||||
.lp .page { max-width: 1000px; margin: 0 auto; background: #FFFFFF; font-family: 'Inter', system-ui, Arial, sans-serif; }
|
||||
|
||||
/* HERO */
|
||||
.lp .hero { background: #2D2D2D; padding: 72px 64px 84px; }
|
||||
.lp .badge {
|
||||
display: inline-block; padding: 7px 16px; border: 0.5px solid rgba(196,168,130,0.5);
|
||||
border-radius: 999px; margin-bottom: 24px; font-size: 11px; letter-spacing: 0.08em;
|
||||
color: #C4A882; font-weight: 500; text-transform: uppercase;
|
||||
}
|
||||
.lp .hero h1 { margin: 0; font-size: 44px; line-height: 1.2; font-weight: 700; color: #FFFFFF; }
|
||||
.lp .hero .accent { margin: 4px 0; font-size: 38px; line-height: 1.3; font-style: italic; color: #C4A882; font-family: Georgia, serif; }
|
||||
.lp .hero p.desc { margin: 28px 0 32px; font-size: 16px; line-height: 1.7; color: #B8B6AE; max-width: 480px; }
|
||||
.lp .btn-sand {
|
||||
display: inline-block; background: #C4A882; color: #2C2C2A; border: none; padding: 15px 30px;
|
||||
border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* La freccia è decorazione (il testo del tag non la include più) */
|
||||
.lp .btn-sand::after { content: ' →'; }
|
||||
.lp .card .buy::after { content: ' →'; }
|
||||
|
||||
/* LAUNCH */
|
||||
.lp .launch { background: #F2ECE1; padding: 72px 64px; }
|
||||
.lp .launch .badge { border-color: rgba(140,107,55,0.35); color: #8A6E3F; }
|
||||
.lp .launch h1 { margin: 0; font-size: 40px; line-height: 1.25; font-weight: 700; color: #2D2D2D; }
|
||||
.lp .launch .accent { margin: 8px 0 28px; font-size: 30px; line-height: 1.35; font-style: italic; color: #8A6E3F; font-family: Georgia, serif; }
|
||||
.lp .launch p.desc { margin: 0; font-size: 16px; line-height: 1.7; color: #5C564A; max-width: 540px; }
|
||||
|
||||
/* PERCHE 5 */
|
||||
.lp .why5 { padding: 48px 64px 8px; }
|
||||
.lp .why5 p { margin: 0; font-size: 18px; line-height: 1.7; font-style: italic; color: #2D2D2D; font-family: Georgia, serif; max-width: 620px; }
|
||||
|
||||
/* COUNTDOWN */
|
||||
/* `hidden` va forzato: i display flex qui sotto avrebbero comunque la meglio sul default UA. */
|
||||
.lp .countdown[hidden] { display: none !important; }
|
||||
.lp .countdown {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
background: #2D2D2D; border-radius: 14px; padding: 26px 24px; margin: 0 0 28px;
|
||||
}
|
||||
.lp .countdown .cd-label {
|
||||
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; color: #C4A882;
|
||||
}
|
||||
.lp .countdown .cd-boxes { display: flex; gap: 10px; }
|
||||
.lp .countdown .cd-box {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 74px;
|
||||
background: rgba(255,255,255,0.06); border: 0.5px solid rgba(196,168,130,0.35);
|
||||
border-radius: 10px; padding: 12px 10px;
|
||||
}
|
||||
.lp .countdown .cd-num { font-size: 30px; font-weight: 700; line-height: 1; color: #FFFFFF; font-variant-numeric: tabular-nums; }
|
||||
.lp .countdown .cd-unit { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #B8B6AE; }
|
||||
|
||||
/* CARDS */
|
||||
.lp .cards-wrap { padding: 32px 64px 72px; }
|
||||
.lp .cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
||||
.lp .card { background: #FAF8F3; border: 0.5px solid #E3DCCB; border-radius: 14px; padding: 26px; position: relative; }
|
||||
.lp .card.featured { border: 2px solid #2D2D2D; }
|
||||
.lp .featured-badge {
|
||||
position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #2D2D2D;
|
||||
color: #FFFFFF; font-size: 11px; padding: 4px 14px; border-radius: 999px; white-space: nowrap; font-weight: 500;
|
||||
}
|
||||
.lp .card .eyebrow { font-size: 11px; letter-spacing: 0.08em; color: #8A8980; margin: 0 0 4px; text-transform: uppercase; font-weight: 700; }
|
||||
.lp .card .title { font-size: 19px; font-weight: 700; color: #2D2D2D; margin: 0 0 14px; }
|
||||
.lp .card .price-row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; }
|
||||
.lp .card .price { font-size: 26px; font-weight: 700; color: #2D2D2D; margin: 0; }
|
||||
.lp .card .price-old { font-size: 15px; color: #ADABA1; text-decoration: line-through; font-weight: 400; }
|
||||
.lp .card .unit { font-size: 12px; color: #8A8980; margin: 0 0 18px; }
|
||||
.lp .card ul { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.lp .card li { font-size: 13.5px; line-height: 1.5; color: #5C564A; display: flex; gap: 8px; }
|
||||
.lp .card li::before { content: "—"; color: #C4A882; font-weight: 700; flex-shrink: 0; }
|
||||
.lp .card .buy {
|
||||
display: block; width: 100%; box-sizing: border-box; text-align: center; text-decoration: none;
|
||||
background: #2D2D2D; color: #FFFFFF; border: none; padding: 13px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.03em; cursor: pointer;
|
||||
}
|
||||
.lp .card.featured .buy { background: #C4A882; color: #2C2C2A; }
|
||||
|
||||
.lp .validity-note { text-align: center; font-size: 12px; color: #8A8980; margin: 22px 0 0; }
|
||||
|
||||
/* CARD DELLE EDIZIONI A TRE (Opening Bundles): domanda in testa, descrizione e riquadro
|
||||
col periodo di utilizzo. Le card restano in colonna, quindi il bottone finisce ad altezze
|
||||
diverse se i testi hanno lunghezze diverse: `flex` + `margin-top:auto` lo tiene in fondo. */
|
||||
.lp .cards-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.lp .cards-3 .card { display: flex; flex-direction: column; }
|
||||
.lp .cards-3 .card .buy { margin-top: auto; }
|
||||
|
||||
/* CARD DELLE EDIZIONI A CINQUE (promo settembre). Cinque non si divide in una griglia a due
|
||||
o a tre colonne senza lasciare un buco in fondo: la griglia è a sei colonne e ogni card ne
|
||||
occupa due, così le ultime due si spostano al centro invece di restare allineate a sinistra
|
||||
con un vuoto accanto. */
|
||||
.lp .cards-5 { grid-template-columns: repeat(6, 1fr); }
|
||||
.lp .cards-5 .card { grid-column: span 2; display: flex; flex-direction: column; }
|
||||
.lp .cards-5 .card .buy { margin-top: auto; }
|
||||
.lp .cards-5 .card:nth-child(4) { grid-column: 2 / span 2; }
|
||||
.lp .cards-5 .card:nth-child(5) { grid-column: 4 / span 2; }
|
||||
.lp .card .question {
|
||||
margin: 0 0 14px; font-size: 15px; line-height: 1.4; font-weight: 700; color: #2D2D2D;
|
||||
}
|
||||
.lp .card .card-desc {
|
||||
margin: 0 0 18px; font-size: 13px; line-height: 1.6; color: #6E685C; font-style: italic;
|
||||
}
|
||||
.lp .card .note-box { background: #F2ECE1; border-radius: 8px; padding: 10px 12px; margin: 0 0 18px; }
|
||||
.lp .card .note-label {
|
||||
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700;
|
||||
color: #8A6E3F; margin-bottom: 3px;
|
||||
}
|
||||
.lp .card .note-text { font-size: 12px; line-height: 1.5; color: #5C564A; }
|
||||
|
||||
/* Pagina senza nessuna promozione attiva: fra un'edizione e la successiva. */
|
||||
.lp .hero.chiusa { padding-bottom: 72px; }
|
||||
.lp .hero.chiusa h1 { font-size: 34px; }
|
||||
.lp .hero.chiusa p.desc { margin-bottom: 28px; }
|
||||
|
||||
/* Barra di anteprima: visibile solo a chi è loggato e ha chiesto una data (?anteprima=…).
|
||||
Deve essere impossibile scambiarla per la pagina vera. */
|
||||
.lp .anteprima {
|
||||
background: #8A6E3F; color: #FFFFFF; font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
|
||||
padding: 10px 16px; text-align: center;
|
||||
}
|
||||
/* Anteprima chiesta e non applicata: colore diverso, perché il contenuto sotto è quello di
|
||||
oggi e non quello della data richiesta. */
|
||||
.lp .anteprima.ignorata { background: #8B2E2E; }
|
||||
.lp .anteprima.ignorata a { color: #FFFFFF; }
|
||||
|
||||
/* FAQ */
|
||||
.lp .faq-wrap { padding: 72px 64px; }
|
||||
.lp .faq-wrap h1 { margin: 0 0 40px; font-size: 34px; line-height: 1.25; font-weight: 700; color: #2D2D2D; max-width: 480px; }
|
||||
.lp .faq-block { border-top: 0.5px solid #E3DCCB; padding: 22px 0; }
|
||||
.lp .faq-block:last-child { border-bottom: 0.5px solid #E3DCCB; }
|
||||
.lp .faq-q { display: flex; justify-content: space-between; align-items: center; gap: 24px; cursor: pointer; }
|
||||
.lp .faq-q p { margin: 0; font-size: 16px; font-weight: 500; color: #2D2D2D; }
|
||||
.lp .faq-q:focus-visible { outline: 2px solid #8A6E3F; outline-offset: 4px; }
|
||||
.lp .faq-toggle {
|
||||
width: 30px; height: 30px; border-radius: 50%; border: 0.5px solid #C9C3B4; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; color: #2D2D2D;
|
||||
transition: background .2s ease, color .2s ease, border-color .2s ease;
|
||||
}
|
||||
.lp .faq-block.is-open .faq-toggle { background: #2D2D2D; color: #FFFFFF; border-color: #2D2D2D; }
|
||||
.lp .faq-a { display: none; margin: 14px 0 0; font-size: 14.5px; line-height: 1.7; color: #8A8980; max-width: 620px; }
|
||||
.lp .faq-block.is-open .faq-a { display: block; }
|
||||
.lp .faq-a .hl { color: #8A6E3F; }
|
||||
|
||||
/* Sotto i 1000px tre colonne diventano troppo strette per un prezzo e un elenco: le cinque
|
||||
card passano a due per riga (griglia a quattro, span due), con la quinta al centro.
|
||||
Nessun `min-width` a chiudere la fascia in basso: a viewport frazionari (uno zoom del
|
||||
browser dà larghezze come 700,5px) fra 700 e 701 non varrebbe nessuna delle due regole e
|
||||
le card tornerebbero a sei colonne. Ci pensa il blocco ≤700px, che viene dopo e a pari
|
||||
specificità vince. */
|
||||
@media (max-width: 1000px) {
|
||||
.lp .cards-5 { grid-template-columns: repeat(4, 1fr); }
|
||||
.lp .cards-5 .card:nth-child(4) { grid-column: span 2; }
|
||||
.lp .cards-5 .card:nth-child(5) { grid-column: 2 / span 2; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.lp .hero, .lp .launch, .lp .why5, .lp .cards-wrap, .lp .faq-wrap { padding-left: 28px; padding-right: 28px; }
|
||||
.lp .cards { grid-template-columns: 1fr; }
|
||||
/* A una colonna gli span e le collocazioni delle ultime due card lascerebbero righe vuote. */
|
||||
.lp .cards-5 .card,
|
||||
.lp .cards-5 .card:nth-child(4),
|
||||
.lp .cards-5 .card:nth-child(5) { grid-column: auto; }
|
||||
.lp .countdown .cd-boxes { gap: 6px; }
|
||||
.lp .countdown .cd-box { min-width: 0; flex: 1; padding: 10px 4px; }
|
||||
.lp .countdown .cd-num { font-size: 22px; }
|
||||
.lp .countdown .cd-unit { font-size: 9.5px; }
|
||||
.lp .hero h1 { font-size: 32px; }
|
||||
.lp .launch h1 { font-size: 28px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { services, getService } from '../../data/services';
|
||||
import { performanceClassPricing } from '../../data/pricing';
|
||||
import { serviceIcons } from '../../data/service-icons';
|
||||
import T from '../../components/content/T.astro';
|
||||
import TImg from '../../components/content/TImg.astro';
|
||||
import CalendarioModal from '../../components/CalendarioModal.astro';
|
||||
import { calendariDaMostrare, PAGINE_CON_CALENDARIO } from '../../data/calendari';
|
||||
import { t } from '../../lib/content';
|
||||
export const prerender = false;
|
||||
|
||||
const service = getService(Astro.params.slug!);
|
||||
// `page: false` = servizio senza scheda (oggi la Performance Class, che non si eroga più):
|
||||
// la voce esiste ancora nei dati per la card in griglia, ma la pagina non c'è.
|
||||
if (!service || service.page === false) return new Response(null, { status: 404 });
|
||||
const slug = service.slug;
|
||||
// Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è
|
||||
// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è
|
||||
// peggio di nessun link.
|
||||
const conCalendario = PAGINE_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0;
|
||||
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false && p.page !== false);
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
||||
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||
const heroImg = imgOf(service.slug);
|
||||
// Categorie come da Figma (Performance class.pdf), non quelle del blog → tag service.side.category.1-4
|
||||
const categoryIndexes = [1, 2, 3, 4];
|
||||
// Ordine card pricing come da Figma: tan, taupe, dark
|
||||
const tones = ['tan', 'taupe', 'dark'];
|
||||
---
|
||||
<Base title={t(`service.${slug}.title`)} description={t(`service.${slug}.excerpt`)}>
|
||||
<PageHero title={(service.heroTitle ?? t(`service.${slug}.title`)).toUpperCase()} bgImage={heroImg} />
|
||||
|
||||
<section class="section">
|
||||
<div class="container prog">
|
||||
<div>
|
||||
<h2>{t(`service.${slug}.title`).toUpperCase()}</h2>
|
||||
<T tag={`service.${slug}.long-description`} as="p" class="prog__desc" />
|
||||
{service.features.length > 0 && (
|
||||
<ul class="prog__features">
|
||||
{service.features.map((_, i) => <T tag={`service.${slug}.feature.${i + 1}`} as="li" />)}
|
||||
</ul>
|
||||
)}
|
||||
{heroImg && (
|
||||
<TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
|
||||
)}
|
||||
{service.extra && (
|
||||
<div class="prog__extra">
|
||||
<T tag={`service.${slug}.extra.title`} as="h2" />
|
||||
<T tag={`service.${slug}.extra.body`} as="p" class="prog__desc" />
|
||||
<ul class="prog__features">
|
||||
{service.extra.items.map((_, i) => <T tag={`service.${slug}.extra.item.${i + 1}`} as="li" />)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div class="prog__faq">
|
||||
{service.faq.map((_, i) => (
|
||||
<details open={i === 0}>
|
||||
<summary><T tag={`service.${slug}.faq.${i + 1}.q`} /></summary>
|
||||
<T tag={`service.${slug}.faq.${i + 1}.a`} as="p" />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="prog__side">
|
||||
<T tag="service.side.categories-label" as="h3" />
|
||||
{categoryIndexes.map((n) => <T tag={`service.side.category.${n}`} as="p" class="prog__cat" />)}
|
||||
<T tag="service.side.others-label" as="h3" />
|
||||
{others.map((o) => (
|
||||
<a class="prog__other" href={`/services/${o.slug}`}>
|
||||
<span class="prog__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={serviceIcons[o.slug]} /></svg>
|
||||
</span>
|
||||
<T tag={`service.${o.slug}.title`} />
|
||||
</a>
|
||||
))}
|
||||
{conCalendario && (
|
||||
<button type="button" class="prog__other prog__other--calendario" data-apri-calendario>
|
||||
<span class="prog__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true">
|
||||
<path d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4M8 14h3M8 17h3M14 14h2" />
|
||||
</svg>
|
||||
</span>
|
||||
{t('calendario.link-label')}
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{service.hasPricing && (
|
||||
<section class="section section--alt">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="pricing.heading" as="h2" />
|
||||
<T tag="pricing.intro" as="p" />
|
||||
</div>
|
||||
{performanceClassPricing.groups.map((g, gi) => (
|
||||
<details class="price__group" open={g.open}>
|
||||
<summary>{g.open ? '+' : '–'} <T tag={`pricing.group.${gi + 1}.label`} /></summary>
|
||||
{g.plans.length === 0 ? (
|
||||
<T tag="service.pricing.empty-note" as="p" class="price__empty" />
|
||||
) : (
|
||||
<div class="price__row">
|
||||
{g.plans.map((p, i) => (
|
||||
<div class:list={['price__card', `price__card--${tones[i % 3]}`]}>
|
||||
<p class="price__amount">{p.monthly}€</p>
|
||||
<T tag="service.pricing.per" as="p" class="price__per" />
|
||||
<p class="price__entries">{p.entries} {p.entries === 1 ? t('service.pricing.entries-singular') : t('service.pricing.entries-plural')}</p>
|
||||
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.note`} as="p" />
|
||||
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.installments`} as="p" />
|
||||
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.best`} as="p" class="price__best" />
|
||||
<a class="btn btn--light" href="/contact">{t('service.pricing.buy-cta')}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{conCalendario && <CalendarioModal />}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.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 { 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; }
|
||||
.prog__faq details { border-top: 1px solid #e4ded6; padding-block: 14px; }
|
||||
.prog__faq summary { cursor: pointer; font-family: var(--font-heading); font-size: .85rem; letter-spacing: .08em; color: var(--c-heading); }
|
||||
.prog__side h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
|
||||
.prog__cat { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .18em; text-transform: uppercase; color: var(--c-text); margin-bottom: 10px; }
|
||||
.prog__other { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--c-text); margin-bottom: 14px; font-family: var(--font-heading); font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.prog__other:hover { color: var(--c-accent-dark); }
|
||||
/* La voce del calendario apre un popup, quindi è un bottone e non un link: qui gli si
|
||||
toglie l'aspetto da bottone perché nella colonna sia indistinguibile dalle altre voci. */
|
||||
.prog__other--calendario { background: none; border: 0; padding: 0; cursor: pointer; width: 100%; text-align: left; }
|
||||
.prog__icon { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: var(--c-accent); color: #fff; flex-shrink: 0; }
|
||||
.price__group { border-top: 1px solid #ddd5ca; padding-block: 18px; }
|
||||
.price__group summary { cursor: pointer; font-family: var(--font-heading); letter-spacing: .12em; text-transform: uppercase; font-size: .85rem; color: var(--c-heading); }
|
||||
.price__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-top: 30px; }
|
||||
/* Card colorate come nel Figma: tan / taupe / dark, testo bianco, prezzo grande */
|
||||
.price__card { padding: 44px 28px; text-align: center; color: #fff; }
|
||||
.price__card--tan { background: var(--c-accent); }
|
||||
.price__card--taupe { background: #6b5d5b; }
|
||||
.price__card--dark { background: var(--c-dark); }
|
||||
.price__card .price__amount { font-family: var(--font-heading); font-size: clamp(2.6rem, 4.7vw, 4rem); font-weight: 500; color: #fff; margin: 0; line-height: 1.1; }
|
||||
.price__card .price__per { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .18em; font-size: .68rem; opacity: .85; margin-bottom: 22px; }
|
||||
.price__card .price__entries { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .1em; font-size: .82rem; border-top: 1px solid rgba(255,255,255,.25); padding-top: 18px; }
|
||||
.price__card p { margin-bottom: 6px; font-size: .82rem; }
|
||||
.price__card .price__best { font-weight: 700; text-transform: uppercase; letter-spacing: .08em; font-size: .8rem; }
|
||||
.price__card .btn { margin-top: 18px; }
|
||||
@media (max-width: 991px) { .prog { grid-template-columns: 1fr; } .price__row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { services } from '../../data/services';
|
||||
import T from '../../components/content/T.astro';
|
||||
import TImg from '../../components/content/TImg.astro';
|
||||
import CalendarioModal from '../../components/CalendarioModal.astro';
|
||||
import { calendariDaMostrare, CARD_CON_CALENDARIO } from '../../data/calendari';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
||||
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||
const gridServices = services.filter((p) => p.grid !== false);
|
||||
// Foto e "Per saperne di più" della stessa card portano allo stesso posto, e lo fanno per
|
||||
// costruzione: erano due espressioni diverse e sono divergute — su One to One e Performance
|
||||
// Class il testo mandava a /training mentre la foto apriva la scheda del servizio, così la
|
||||
// destinazione dipendeva da dove capitava il dito.
|
||||
// Alcune card non portano alla propria scheda ma a una vista di /training (One to One e
|
||||
// Class, deciso dal cliente il 17/08/2026): la destinazione la dichiara il servizio.
|
||||
const hrefOf = (p: (typeof services)[number]) => p.href ?? `/services/${p.slug}`;
|
||||
// Il calendario delle class si apre anche da qui, dalla card Class: chi guarda i servizi
|
||||
// vuole sapere quando si allena prima ancora di aprire la scheda. Compare solo se c'è
|
||||
// davvero una griglia da mostrare, come nelle pagine delle class.
|
||||
const conCalendario = calendariDaMostrare(new Date()).length > 0;
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title={t('services.meta.title')} description={t('services.meta.description')}>
|
||||
<PageHero title={t('services.hero.title')} />
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="services.intro.body" as="p" />
|
||||
</div>
|
||||
<div class="plist">
|
||||
{gridServices.map((p, i) => (
|
||||
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
|
||||
{imgOf(p.slug) && (
|
||||
<a class="plist__photo" href={hrefOf(p)}>
|
||||
<TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
|
||||
</a>
|
||||
)}
|
||||
<div class="plist__body">
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
|
||||
<T tag={`service.${p.slug}.excerpt`} as="p" />
|
||||
{conCalendario && CARD_CON_CALENDARIO.includes(p.slug) && (
|
||||
<button type="button" class="plist__calendario" data-apri-calendario>{t('calendario.link-label')}</button>
|
||||
)}
|
||||
<a class="plist__more" href={hrefOf(p)}>{t('services.card.more-label')}</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{conCalendario && <CalendarioModal />}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.plist { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px 30px; }
|
||||
.plist__card { background: #fff; box-shadow: 0 6px 24px rgba(0,0,0,.08); display: flex; flex-direction: column; }
|
||||
.plist__photo { display: block; overflow: hidden; }
|
||||
.plist__photo img { aspect-ratio: 4/3; object-fit: cover; width: 100%; transition: transform .5s ease; }
|
||||
.plist__card:hover .plist__photo img { transform: scale(1.05); }
|
||||
.plist__body { padding: 26px 24px 30px; display: flex; flex-direction: column; align-items: flex-start; flex: 1; }
|
||||
.plist__card h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .3em; }
|
||||
.plist__sub { font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; color: var(--c-accent-dark); }
|
||||
.plist__body > p:not(.plist__sub) { font-size: .9rem; white-space: pre-line; }
|
||||
.plist__more { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); margin-top: auto; }
|
||||
.plist__more:hover { color: var(--c-accent-dark); }
|
||||
/* Il calendario sta sopra "Per saperne di più" (posizione chiesta dal cliente) e apre un
|
||||
popup, quindi è un bottone: qui gli si toglie l'aspetto da bottone perché nella card
|
||||
stia accanto all'altra voce senza rubarle l'occhio. */
|
||||
.plist__calendario { background: none; border: 0; padding: 0; margin-top: auto; cursor: pointer; text-align: left; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent-dark); }
|
||||
.plist__calendario:hover { color: var(--c-heading); }
|
||||
.plist__calendario + .plist__more { margin-top: 10px; }
|
||||
@media (max-width: 991px) { .plist { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 600px) { .plist { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../lib/db';
|
||||
import { services } from '../data/services';
|
||||
export const prerender = false;
|
||||
|
||||
const STATIC = ['/', '/about', '/contact', '/training', '/services', '/blog', '/promo-eventi', '/privacy', '/cookie-policy'];
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, '');
|
||||
const posts = getDb().prepare('SELECT slug FROM posts WHERE draft = 0').all() as { slug: string }[];
|
||||
const urls = [
|
||||
...STATIC,
|
||||
...services.map((p) => `/services/${p.slug}`),
|
||||
...posts.map((p) => `/blog/${p.slug}`),
|
||||
];
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => ` <url><loc>${base}${u}</loc></url>`).join('\n')}\n</urlset>`;
|
||||
return new Response(xml, { headers: { 'Content-Type': 'application/xml' } });
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import TrainingCards from '../../components/home/TrainingCards.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { VISTE_TRAINING, trainingDaMostrare } from '../../data/trainings';
|
||||
import T from '../../components/content/T.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
|
||||
export const prerender = false;
|
||||
|
||||
// `/training` mostra tutto; `/training/one-to-one` e `/training/class` il solo sottoinsieme
|
||||
// da cui si arriva cliccando la card corrispondente in /services. Un indirizzo inventato è
|
||||
// 404 e non l'elenco intero: un filtro che non c'è deve vedersi, non passare per buono.
|
||||
const vista = Astro.params.vista;
|
||||
const daMostrare = trainingDaMostrare(vista);
|
||||
if (!daMostrare) return new Response(null, { status: 404 });
|
||||
const filtrata = Boolean(vista);
|
||||
---
|
||||
<Base title={t('training.meta.title')} description={t('training.meta.description')}>
|
||||
<PageHero title={t('training.hero.title')} />
|
||||
|
||||
<section class="section" style="padding-bottom:0">
|
||||
<div class="section-heading">
|
||||
{filtrata ? <p>{VISTE_TRAINING[vista!].titolo}</p> : <T tag="training.intro.subtitle" as="p" />}
|
||||
</div>
|
||||
</section>
|
||||
<TrainingCards items={daMostrare} />
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
{daMostrare.map((tr, i) => (
|
||||
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
|
||||
{imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
|
||||
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
|
||||
<h2 data-tag={`training.${tr.id}.title`}>{t(`training.${tr.id}.title`)}</h2>
|
||||
<T tag={`training.${tr.id}.description`} as="p" />
|
||||
<a class="btn" href={tr.cta.href}><T tag={`training.${tr.id}.cta-label`} as="span" /></a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Da una vista filtrata si esce: senza questo link chi è entrato da una card di
|
||||
/services non ha modo di vedere gli altri training se non dal menu. */}
|
||||
{filtrata && (
|
||||
<p class="tblock__tutti"><a class="btn btn--light" href="/training">{t('training.tutti.cta-label')}</a></p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.tblock { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; scroll-margin-top: calc(var(--header-h) + 20px); }
|
||||
.tblock--reverse img { order: 2; }
|
||||
.tblock h2 { letter-spacing: .1em; }
|
||||
.tblock__tutti { text-align: center; margin-top: 20px; }
|
||||
@media (max-width: 767px) { .tblock { grid-template-columns: 1fr; gap: 24px; } .tblock--reverse img { order: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, normalize, extname } from 'node:path';
|
||||
import { getEnv } from '../../lib/env';
|
||||
export const prerender = false;
|
||||
|
||||
const MIME: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
|
||||
if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
|
||||
const mime = MIME[extname(rel).toLowerCase()];
|
||||
if (!mime) return new Response('Not found', { status: 404 });
|
||||
try {
|
||||
const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
|
||||
return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
|
||||
} catch {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user