Struttura: il sito esistente si sposta sotto apps/sito
Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09: due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna resti un progetto Astro completo ed estraibile. Qui non cambia niente del codice: solo la radice. I pattern del .gitignore che avevano una barra erano ancorati alla radice e dopo lo spostamento non avrebbero piu' coperto niente (data/*.db*, uploads/*): resi validi a qualunque profondita'. Suite: 45 file, 347 test verdi prima e dopo lo spostamento. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
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,31 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { login, getSessionUser, landingFor, 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) {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
// Ogni ruolo atterra sulla sua sezione: campus fuori dal pannello, gli
|
||||
// altri sulla pagina admin che possono effettivamente aprire.
|
||||
return Astro.redirect(landingFor(getSessionUser(getDb(), token)?.role ?? 'user'));
|
||||
}
|
||||
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,67 @@
|
||||
---
|
||||
// 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',
|
||||
piattaforme: 'solo Campus e Stress Index',
|
||||
admin: 'accesso completo',
|
||||
cliente: 'solo il proprio fascicolo Longevity',
|
||||
trainer: 'gestionale Longevity',
|
||||
};
|
||||
---
|
||||
<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,94 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getLongevityDb } from '../../../lib/longevity/db';
|
||||
import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
|
||||
import { salvaDalForm } from '../../../lib/longevity/vista';
|
||||
import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare.
|
||||
* `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una
|
||||
* lista così invecchia appena il file rinomina la variabile, vedi il commento nel test),
|
||||
* ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non
|
||||
* supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello
|
||||
* della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui.
|
||||
*/
|
||||
type CorpoQuestionario = {
|
||||
risposte: Record<string, number>;
|
||||
liberi: Record<string, string>;
|
||||
consensoSanitario: string;
|
||||
eta?: number;
|
||||
};
|
||||
|
||||
/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è
|
||||
* nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un
|
||||
* eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare
|
||||
* al chiamante, perché il tipo di ritorno non la contiene. */
|
||||
function corpoDaJson(raw: unknown): CorpoQuestionario | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const grezzo = raw as Record<string, unknown>;
|
||||
|
||||
const risposteIn = grezzo.risposte;
|
||||
if (typeof risposteIn !== 'object' || risposteIn === null) return null;
|
||||
const risposte: Record<string, number> = {};
|
||||
for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
|
||||
}
|
||||
|
||||
// Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
|
||||
const liberiIn = grezzo.liberi;
|
||||
const liberi: Record<string, string> = {};
|
||||
if (typeof liberiIn === 'object' && liberiIn !== null) {
|
||||
for (const campo of CAMPI_LIBERI) {
|
||||
const v = (liberiIn as Record<string, unknown>)[campo];
|
||||
if (typeof v === 'string') liberi[campo] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : '';
|
||||
const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined;
|
||||
|
||||
return { risposte, liberi, consensoSanitario, eta };
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
// Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
|
||||
// corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato
|
||||
// a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui
|
||||
// prenderlo per errore.
|
||||
const codice = codicePerUtenteLoggato(locals.user!.id);
|
||||
if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
|
||||
|
||||
let raw: unknown;
|
||||
try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' });
|
||||
|
||||
const corpo = corpoDaJson(raw);
|
||||
if (!corpo) return json(400, { error: 'Risposte mancanti.' });
|
||||
const { risposte, liberi, consensoSanitario, eta } = corpo;
|
||||
|
||||
// La data della compilazione è quella del server, non quella (manomettibile) del client.
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// salvaDalForm delega a salvaCompilazione, che lancia se una risposta porta un test_id
|
||||
// sconosciuto al registro (client disallineato, non serve malizia): un'eccezione qui
|
||||
// non deve diventare un 500 generico, come in src/pages/api/contact.ts per l'invio email.
|
||||
let esito;
|
||||
try {
|
||||
esito = salvaDalForm(getLongevityDb(), {
|
||||
client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Salvataggio questionario fallito:', err);
|
||||
return json(400, {
|
||||
error: 'Impossibile salvare: alcune risposte non sono riconosciute. Ricarica la pagina e riprova.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!esito.ok) return json(422, { error: esito.errore });
|
||||
return json(201, { sessioneId: esito.sessioneId });
|
||||
};
|
||||
@@ -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,113 @@
|
||||
---
|
||||
// Due forme di URL:
|
||||
// /campus/<area> → indice dei capitoli dell'area
|
||||
// /campus/<area>/<guida> → corpo della guida
|
||||
import Campus from '../../layouts/Campus.astro';
|
||||
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
|
||||
export const prerender = false;
|
||||
|
||||
const nav = getNav();
|
||||
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
|
||||
|
||||
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
|
||||
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const [areaSlug, lessonSlug] = parts;
|
||||
const area = findArea(nav, areaSlug);
|
||||
if (!area) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
|
||||
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const body = lesson ? readLessonHtml(lesson.slug) : null;
|
||||
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
|
||||
|
||||
const chapter = lesson ? chapterOf(nav, lesson) : null;
|
||||
---
|
||||
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
|
||||
{lesson ? (
|
||||
<article>
|
||||
<nav class="cp-crumbs">
|
||||
<a href="/campus">Biohacking</a> <span>/</span>
|
||||
<a href={`/campus/${area.slug}`}>{area.title}</a>
|
||||
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
|
||||
</nav>
|
||||
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
|
||||
<h1>{lesson.titleFull}</h1>
|
||||
{lesson.concetti.length > 0 && (
|
||||
<ul class="cp-concetti">
|
||||
{lesson.concetti.map((c) => <li>{c}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div class="cp-body" set:html={body} />
|
||||
<nav class="cp-prevnext">
|
||||
{lesson.prev
|
||||
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
|
||||
: <span />}
|
||||
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
|
||||
</nav>
|
||||
</article>
|
||||
) : (
|
||||
<>
|
||||
<nav class="cp-crumbs"><a href="/campus">Biohacking</a> <span>/</span> <span>{area.title}</span></nav>
|
||||
<p class="eyebrow">Area di studio</p>
|
||||
<h1>{area.title}</h1>
|
||||
<p class="cp-lead">{area.sub}</p>
|
||||
{area.chapters.map((ch) => (
|
||||
<section class="cp-chapter" style={`--c:${ch.color}`}>
|
||||
<h2>{ch.num}. {ch.title}</h2>
|
||||
<p class="cp-chapter__sub">{ch.sub}</p>
|
||||
<ul class="cp-list">
|
||||
{ch.lessons.map((l) => (
|
||||
<li>
|
||||
<a href={`/campus/${area.slug}/${l.slug}`}>
|
||||
<span class="cp-list__label">{l.label}</span>
|
||||
<span class="cp-list__title">{l.titleShort}</span>
|
||||
<span class="cp-list__rel">{l.relatore}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Campus>
|
||||
|
||||
<style is:global>
|
||||
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
|
||||
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
|
||||
.cp-crumbs a:hover { text-decoration: underline; }
|
||||
.cp-crumbs span { margin: 0 4px; }
|
||||
|
||||
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
|
||||
|
||||
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
|
||||
border-left: 3px solid var(--c-accent); }
|
||||
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
|
||||
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
|
||||
|
||||
.cp-chapter { margin-bottom: 42px; }
|
||||
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
|
||||
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
|
||||
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
|
||||
|
||||
.cp-list { list-style: none; margin: 0; padding: 0; }
|
||||
.cp-list li { border-bottom: 1px solid #efece7; }
|
||||
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
|
||||
text-decoration: none; color: inherit; padding: 11px 16px; }
|
||||
.cp-list a:hover { background: var(--c-bg-alt); }
|
||||
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
|
||||
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
|
||||
|
||||
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
|
||||
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
|
||||
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
|
||||
.cp-prevnext a:hover { text-decoration: underline; }
|
||||
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cp-list a { grid-template-columns: 56px 1fr; }
|
||||
.cp-list__rel { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Immagini delle slide del Campus. Passano dal middleware come il resto di
|
||||
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createReadStream, statSync } from 'node:fs';
|
||||
import { extname } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { resolveAsset } from '../../../lib/campus';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const rel = params.path;
|
||||
if (!rel) return new Response('Not found', { status: 404 });
|
||||
|
||||
const type = TYPES[extname(rel).toLowerCase()];
|
||||
if (!type) return new Response('Tipo non consentito', { status: 400 });
|
||||
|
||||
const file = resolveAsset(rel);
|
||||
if (!file) return new Response('Not found', { status: 404 });
|
||||
|
||||
const { size } = statSync(file);
|
||||
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Content-Length': String(size),
|
||||
'Cache-Control': 'private, max-age=86400',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import Campus from '../../layouts/Campus.astro';
|
||||
import { getNav } from '../../lib/campus';
|
||||
export const prerender = false;
|
||||
|
||||
const nav = getNav();
|
||||
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
|
||||
---
|
||||
<Campus title="Guida allo studio" nav={nav}>
|
||||
<p class="eyebrow">Biohacking Campus 2025/2026</p>
|
||||
<h1>Guida allo studio</h1>
|
||||
<p class="cp-lead">
|
||||
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
|
||||
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
|
||||
</p>
|
||||
|
||||
<div class="cp-grid">
|
||||
{nav.areas.map((area) => (
|
||||
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
|
||||
<h2>{area.title}</h2>
|
||||
<p>{area.sub}</p>
|
||||
<ul>
|
||||
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
|
||||
</ul>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Campus>
|
||||
|
||||
<style is:global>
|
||||
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
|
||||
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
|
||||
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
|
||||
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
|
||||
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
|
||||
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
|
||||
text-transform: uppercase; }
|
||||
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
|
||||
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
|
||||
font-size: .82rem; color: var(--c-text-light); }
|
||||
.cp-card ul li { margin: 0; padding: 2px 0; }
|
||||
.cp-card ul span { color: var(--c-accent-dark); }
|
||||
</style>
|
||||
@@ -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,48 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { getDb } from '../lib/db';
|
||||
import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
||||
import { safeNext } from '../lib/safe-next';
|
||||
export const prerender = false;
|
||||
|
||||
const dest = safeNext(Astro.url.searchParams.get('next'));
|
||||
|
||||
// Già loggato → via dal login.
|
||||
const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
|
||||
|
||||
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) {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
|
||||
}
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
---
|
||||
<Base title="Accedi" description="Area riservata InsanityLab">
|
||||
<section class="section">
|
||||
<div class="container login-wrap">
|
||||
<h1 class="section-heading">Accedi</h1>
|
||||
{error && <p class="login-err">{error}</p>}
|
||||
<form method="post" class="login-form">
|
||||
<input type="hidden" name="next" value={dest} />
|
||||
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
|
||||
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||
<button class="btn btn--dark" type="submit">Entra</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.login-wrap { max-width: 380px; margin: 0 auto; }
|
||||
.login-form { display: grid; gap: 14px; margin-top: 20px; }
|
||||
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
|
||||
.login-err { color: #a33; }
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
// Punto di ingresso della piattaforma: non c'è un indice, solo la scorciatoia al
|
||||
// referto del cliente. La protezione della rotta è già del middleware (isProtectedPath
|
||||
// e canAccessAdminPath in src/lib/auth.ts).
|
||||
export const prerender = false;
|
||||
return Astro.redirect('/longevity/io');
|
||||
---
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
// Il referto del cliente: il radar a sette assi, la Fitness Age, le carte dei macro-score.
|
||||
// La pagina non interroga il database da sola (c'è un test che lo verifica): il codice
|
||||
// cliente arriva da codicePerUtenteLoggato (anagrafica.ts, l'unico modulo autorizzato a
|
||||
// toccare identity), la sessione più recente e il referto stesso da vista.ts.
|
||||
import Longevity from '../../layouts/Longevity.astro';
|
||||
import Radar from '../../components/longevity/Radar';
|
||||
import MacroScore from '../../components/longevity/MacroScore.astro';
|
||||
import { getLongevityDb } from '../../lib/longevity/db';
|
||||
import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica';
|
||||
import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista';
|
||||
import { formattaNumero } from '../../lib/longevity/formato';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const codice = codicePerUtenteLoggato(user.id);
|
||||
const db = getLongevityDb();
|
||||
const sessioneId = codice ? ultimaSessioneDi(db, codice) : null;
|
||||
const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null;
|
||||
---
|
||||
<Longevity title="Il tuo referto" crumbs={['Il tuo referto']}>
|
||||
{!codice && (
|
||||
<div class="lg-card">
|
||||
<p>Nessun fascicolo è ancora collegato al tuo account. Contatta lo staff InsanityLab.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codice && !referto && (
|
||||
<div class="lg-card lg-card--incompleto">
|
||||
<p>Non hai ancora compilato il questionario: senza quello non c'è ancora un referto da mostrare.</p>
|
||||
<p><a href="/longevity/questionario">Comincia da qui</a>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{referto && (
|
||||
<div class="lg-referto">
|
||||
<section class:list={['lg-card', { 'lg-card--incompleto': referto.fitnessAge === null }]}>
|
||||
<p class="lg-score__label">Fitness Age</p>
|
||||
<div class="lg-score">
|
||||
<span class="lg-score__value lg-num">
|
||||
{referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'}
|
||||
</span>
|
||||
{referto.fitnessAge !== null && <span class="lg-score__max">anni</span>}
|
||||
</div>
|
||||
<p class="lg-muted lg-macro__nota">
|
||||
Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}%
|
||||
{referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="lg-card">
|
||||
<h2>Il tuo radar</h2>
|
||||
<Radar client:load assi={referto.assi} />
|
||||
</section>
|
||||
|
||||
<section class="lg-macro">
|
||||
{referto.macro.map((m) => (
|
||||
<MacroScore nome={m.nome} valore={m.valore} copertura={m.copertura} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="lg-card">
|
||||
<p>
|
||||
InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni
|
||||
raccontano come cambi, non solo come stai oggi.
|
||||
<a href="/longevity/questionario">Compila di nuovo il questionario</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Longevity>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
|
||||
// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
|
||||
// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
|
||||
// /api/longevity/questionario, dove il consenso si verifica davvero.
|
||||
import Longevity from '../../layouts/Longevity.astro';
|
||||
import Questionario from '../../components/longevity/Questionario';
|
||||
import { getLongevityDb } from '../../lib/longevity/db';
|
||||
import { campiDelQuestionario } from '../../lib/longevity/vista';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
const campi = campiDelQuestionario(getLongevityDb(), oggi);
|
||||
---
|
||||
<Longevity title="Questionario" crumbs={['Questionario']}>
|
||||
<div class="lg-head">
|
||||
<h1>Questionario</h1>
|
||||
<p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
|
||||
</div>
|
||||
<Questionario client:load campi={campi} />
|
||||
</Longevity>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
// Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme`
|
||||
// (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware.
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { piattaforme } from '../../data/piattaforme';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title="Piattaforme" description="Le piattaforme riservate InsanityLab.">
|
||||
<section class="section plat-index">
|
||||
<div class="container">
|
||||
<p class="plat-index__eyebrow">Area riservata</p>
|
||||
<h1>Piattaforme</h1>
|
||||
<p class="plat-index__lead">Gli strumenti riservati agli utenti abilitati.</p>
|
||||
<div class="plat-index__grid">
|
||||
{piattaforme.map((p) => (
|
||||
<a class="plat-card" href={p.href}>
|
||||
<h2 class="plat-card__title">{p.label}</h2>
|
||||
<p class="plat-card__desc">{p.description}</p>
|
||||
<span class="plat-card__cta">Entra</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.plat-index__eyebrow {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent-dark); margin: 0 0 .6em;
|
||||
}
|
||||
.plat-index__lead { max-width: 46ch; color: var(--c-text-light); margin-bottom: 46px; }
|
||||
.plat-index__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
|
||||
.plat-card {
|
||||
display: block; text-decoration: none; background: var(--c-bg-alt); padding: 34px 30px 30px;
|
||||
border-top: 3px solid var(--c-accent); transition: transform .3s ease, box-shadow .3s ease;
|
||||
}
|
||||
.plat-card:hover { transform: translateY(-4px); box-shadow: 0 14px 30px rgba(0,0,0,.08); }
|
||||
.plat-card__title { font-size: 1.25rem; margin-bottom: .4em; }
|
||||
.plat-card__desc { color: var(--c-text-light); font-size: .92rem; margin-bottom: 1.4em; }
|
||||
.plat-card__cta {
|
||||
font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; color: var(--c-accent-dark);
|
||||
}
|
||||
.plat-card__cta::after { content: ' →'; }
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
// Analytics di studio. La pagina fornisce i dati e l'intervallo iniziale; il resto — filtri,
|
||||
// aggregazioni e grafici — è nell'isola, perché ogni numero dipende dai controlli.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import AnalyticsView from '../../../components/stress-index/AnalyticsView';
|
||||
import { clients, measurementsByClient, alerts } from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
// Mese corrente dei dati dimostrativi.
|
||||
const DA = '2026-07-01';
|
||||
const A = '2026-07-31';
|
||||
---
|
||||
<StressIndex title="Analytics" crumbs={['Analytics']}>
|
||||
<AnalyticsView
|
||||
client:load
|
||||
clients={clients}
|
||||
measurementsByClient={measurementsByClient}
|
||||
alerts={alerts}
|
||||
from={DA}
|
||||
to={A}
|
||||
/>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
// Scheda del singolo cliente. La pagina raccoglie i dati e li passa all'isola React che
|
||||
// gestisce tab e interazioni.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import ClientDetail from '../../../../components/stress-index/ClientDetail';
|
||||
import { findClient, measurementsByClient, alerts, notes } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const { id } = Astro.params;
|
||||
const client = findClient(id ?? '');
|
||||
if (!client) return Astro.redirect('/piattaforme/stress-index/clienti');
|
||||
---
|
||||
<StressIndex title={client.name} crumbs={['Clienti', client.name]}>
|
||||
<ClientDetail
|
||||
client:load
|
||||
client={client}
|
||||
measurements={measurementsByClient[client.id] ?? []}
|
||||
alerts={alerts.filter((a) => a.clientId === client.id)}
|
||||
notes={notes.filter((n) => n.clientId === client.id)}
|
||||
/>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
// Elenco clienti (CRM). Il filtro per nome/email è puro JS sulle righe già renderizzate:
|
||||
// i clienti sono una decina, non vale un'isola React per cercarli.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../../components/stress-index/Card.astro';
|
||||
import { clients } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const livello = (stress: number) => (stress >= 70 ? 'bad' : stress >= 45 ? 'warn' : 'ok');
|
||||
---
|
||||
<StressIndex title="Clienti" crumbs={['Clienti']}>
|
||||
<div class="si-head">
|
||||
<h1>Clienti</h1>
|
||||
<p>{clients.length} in carico</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div class="si-toolbar">
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Cerca cliente</span>
|
||||
<input type="search" id="si-cerca" placeholder="Cerca per nome o email" autocomplete="off" />
|
||||
</label>
|
||||
<p class="si-muted si-small" id="si-conteggio">{clients.length} risultati</p>
|
||||
</div>
|
||||
|
||||
<table class="si-table" id="si-tabella-clienti">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente</th><th>Età</th><th>Tag</th><th>Ultima misurazione</th><th>Stress</th><th>Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((c) => (
|
||||
<tr data-cerca={`${c.name} ${c.email}`.toLowerCase()}>
|
||||
<td>
|
||||
<a class="si-client" href={`/piattaforme/stress-index/clienti/${c.id}`}>
|
||||
<span class="si-avatar">{c.initials}</span>
|
||||
<span>
|
||||
<span class="si-client__name">{c.name}</span>
|
||||
<span class="si-client__mail">{c.email}</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{c.age} anni</td>
|
||||
<td>{c.tags[0] && <span class="si-tag">{c.tags[0]}</span>}</td>
|
||||
<td>{c.lastMeasurement}</td>
|
||||
<td>
|
||||
<div class="si-meter">
|
||||
<span class="si-bar">
|
||||
<span class:list={['si-bar__fill', `si-bar__fill--${livello(c.stress)}`]} style={`width:${c.stress}%`}></span>
|
||||
</span>
|
||||
<span class="si-table__num">{c.stress}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{c.alerts > 0
|
||||
? <span class:list={['si-badge', c.alerts >= 2 ? 'si-badge--bad' : 'si-badge--warn']}>{c.alerts}</span>
|
||||
: <span class="si-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="si-muted si-small si-empty" id="si-vuoto" hidden>Nessun cliente corrisponde alla ricerca.</p>
|
||||
</Card>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Filtro dell'elenco: nasconde le righe che non contengono il testo cercato e tiene
|
||||
// aggiornato il conteggio dei risultati.
|
||||
const input = document.getElementById('si-cerca') as HTMLInputElement | null;
|
||||
const table = document.getElementById('si-tabella-clienti');
|
||||
const counter = document.getElementById('si-conteggio');
|
||||
const empty = document.getElementById('si-vuoto');
|
||||
if (input && table && counter && empty) {
|
||||
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>('tbody tr'));
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const row of rows) {
|
||||
const match = !q || (row.dataset.cerca ?? '').includes(q);
|
||||
row.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
counter.textContent = `${visible} ${visible === 1 ? 'risultato' : 'risultati'}`;
|
||||
empty.hidden = visible > 0;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
// Impostazioni: profilo, notifiche, account. I campi sono ancora un modulo dimostrativo —
|
||||
// non c'è un endpoint di salvataggio — ma la struttura è quella definitiva.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import { professional } from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const [nome = '', cognome = ''] = professional.name.replace(/^Dr\.?\s*/i, '').split(' ');
|
||||
const GIORNI = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica'];
|
||||
const ORE = ['07:00', '08:00', '09:00', '18:00', '19:00', '20:00'];
|
||||
const TABS = [
|
||||
{ key: 'profilo', label: 'Profilo' },
|
||||
{ key: 'notifiche', label: 'Notifiche' },
|
||||
{ key: 'account', label: 'Account' },
|
||||
];
|
||||
---
|
||||
<StressIndex title="Impostazioni" crumbs={['Impostazioni']}>
|
||||
<div class="si-head">
|
||||
<h1>Impostazioni</h1>
|
||||
<p>Profilo, notifiche e account</p>
|
||||
</div>
|
||||
|
||||
<div class="si-tabs" role="tablist" id="si-set-tabs">
|
||||
{TABS.map((t, i) => (
|
||||
<button type="button" role="tab" class="si-tabs__btn" data-tab={t.key} aria-selected={i === 0 ? 'true' : 'false'}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section data-panel="profilo" class="si-stack">
|
||||
<Card title="Dati professionista">
|
||||
<div class="si-form-grid">
|
||||
<label class="si-field">Titolo<input type="text" value="Dr." /></label>
|
||||
<label class="si-field">Professione<input type="text" value={professional.role} /></label>
|
||||
<label class="si-field">Nome<input type="text" value={nome} /></label>
|
||||
<label class="si-field">Cognome<input type="text" value={cognome} /></label>
|
||||
<label class="si-field">Specializzazione<input type="text" value={professional.role} /></label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Studio">
|
||||
<div class="si-form-grid">
|
||||
<label class="si-field">Nome studio<input type="text" value={professional.studio} /></label>
|
||||
<label class="si-field">Indirizzo<input type="text" placeholder="Via Roma 1, Bologna" /></label>
|
||||
<label class="si-field">Telefono<input type="tel" placeholder="+39 051 123456" /></label>
|
||||
<label class="si-field">Sito web<input type="url" placeholder="https://studio.it" /></label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva</button></p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="notifiche" hidden>
|
||||
<Card title="Preferenze di notifica">
|
||||
<ul class="si-list si-list--rows si-prefs">
|
||||
<li>
|
||||
<span>
|
||||
<strong>Riassunto settimanale via email</strong>
|
||||
<span class="si-muted si-small">Un riepilogo periodico dell'andamento dei clienti</span>
|
||||
</span>
|
||||
<input type="checkbox" checked />
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>Alert immediati</strong>
|
||||
<span class="si-muted si-small">Email non appena viene generato un alert cliente</span>
|
||||
</span>
|
||||
<input type="checkbox" checked />
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>Novità di prodotto</strong>
|
||||
<span class="si-muted si-small">Aggiornamenti sulle nuove funzionalità</span>
|
||||
</span>
|
||||
<input type="checkbox" />
|
||||
</li>
|
||||
</ul>
|
||||
<div class="si-form-grid si-mt">
|
||||
<label class="si-field">Giorno dell'invio
|
||||
<select>{GIORNI.map((g) => <option>{g}</option>)}</select>
|
||||
</label>
|
||||
<label class="si-field">Ora dell'invio
|
||||
<select>{ORE.map((o) => <option>{o}</option>)}</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Salva preferenze</button></p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section data-panel="account" hidden class="si-stack">
|
||||
<Card title="Accesso">
|
||||
<label class="si-field">Email<input type="email" value={professional.email} readonly /></label>
|
||||
<p class="si-muted si-small si-mt">Per cambiare l'indirizzo email serve una richiesta al supporto.</p>
|
||||
<div class="si-form-grid si-mt">
|
||||
<label class="si-field">Password attuale<input type="password" autocomplete="current-password" /></label>
|
||||
<label class="si-field">Nuova password<input type="password" autocomplete="new-password" /></label>
|
||||
<label class="si-field">Conferma nuova password<input type="password" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<p class="si-mt"><button type="button" class="si-btn si-btn--solid">Aggiorna password</button></p>
|
||||
</Card>
|
||||
|
||||
<Card title="Abbonamento">
|
||||
<p class="si-muted si-small">Per il piano e la fatturazione fa riferimento al supporto Stress Index.</p>
|
||||
</Card>
|
||||
|
||||
<p><a class="si-btn" href="/admin/logout">Esci dall'account</a></p>
|
||||
</section>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
const tabs = document.getElementById('si-set-tabs');
|
||||
if (tabs) {
|
||||
const buttons = Array.from(tabs.querySelectorAll<HTMLButtonElement>('.si-tabs__btn'));
|
||||
const panels = Array.from(document.querySelectorAll<HTMLElement>('[data-panel]'));
|
||||
for (const btn of buttons) {
|
||||
btn.addEventListener('click', () => {
|
||||
for (const b of buttons) b.setAttribute('aria-selected', String(b === btn));
|
||||
for (const p of panels) p.hidden = p.dataset.panel !== btn.dataset.tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
// Dashboard "Oggi": ciò che il professionista deve guardare per primo — alert, clienti da
|
||||
// ricontattare, note recenti. L'accesso è già filtrato dal middleware (ruolo piattaforme/admin).
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
import Card from '../../../components/stress-index/Card.astro';
|
||||
import Kpi from '../../../components/stress-index/Kpi.astro';
|
||||
import AlertRow from '../../../components/stress-index/AlertRow.astro';
|
||||
import AndamentoChart from '../../../components/stress-index/charts/AndamentoChart';
|
||||
import {
|
||||
alerts, clients, measurementsByClient, notes, studioKpis, professional, OGGI, daysSince,
|
||||
} from '../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
const prioritari = alerts.slice(0, 4);
|
||||
const misurazioniOggi = Object.values(measurementsByClient).flat().filter((m) => m.date === OGGI).length;
|
||||
const daContattare = [...clients]
|
||||
.sort((a, b) => daysSince(b.lastMeasurement) - daysSince(a.lastMeasurement))
|
||||
.slice(0, 5);
|
||||
const ultimeNote = [...notes]
|
||||
.sort((a, b) => (a.when < b.when ? 1 : -1))
|
||||
.slice(0, 4)
|
||||
.map((n) => ({ ...n, clientName: clients.find((c) => c.id === n.clientId)?.name }));
|
||||
---
|
||||
<StressIndex title="Oggi">
|
||||
<div class="si-head">
|
||||
<h1>Buongiorno, {professional.name.replace(/^Dr\.\s*/, '')}</h1>
|
||||
<p>Oggi, {OGGI}</p>
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--kpi">
|
||||
<Kpi label="Clienti attivi" value={studioKpis.totalClients} delta="+2 questo mese" trend="up" />
|
||||
<Kpi
|
||||
label="Misurazioni oggi"
|
||||
value={misurazioniOggi}
|
||||
delta={misurazioniOggi === 0 ? 'Nessuna ancora' : 'In aggiornamento'}
|
||||
trend={misurazioniOggi === 0 ? 'down' : 'up'}
|
||||
/>
|
||||
<Kpi label="Alert attivi" value={studioKpis.activeAlerts} delta="+3 questa settimana" trend="down" />
|
||||
<Kpi label="Stress medio" value={studioKpis.avgStress} delta="-4% vs mese scorso" trend="up" />
|
||||
</div>
|
||||
|
||||
<div class="si-grid si-grid--2 si-mt">
|
||||
<div class="si-stack">
|
||||
<Card title="Andamento medio aggregato">
|
||||
<AndamentoChart client:load measurementsByClient={measurementsByClient} />
|
||||
</Card>
|
||||
|
||||
<Card title="Alert prioritari" actionLabel="Vedi tutti" actionHref="/piattaforme/stress-index/clienti">
|
||||
{prioritari.map((a) => <AlertRow alert={a} />)}
|
||||
</Card>
|
||||
|
||||
<Card title="Da contattare">
|
||||
<ul class="si-list si-list--rows">
|
||||
{daContattare.map((c) => (
|
||||
<li>
|
||||
<span>
|
||||
<strong>{c.name}</strong>
|
||||
<span class="si-muted"> · ultima misurazione {daysSince(c.lastMeasurement)} giorni fa</span>
|
||||
</span>
|
||||
<a class="si-btn" href={`/piattaforme/stress-index/clienti/${c.id}`}>Scheda</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="si-stack">
|
||||
<Card title="Misurazioni di oggi">
|
||||
{misurazioniOggi === 0 ? (
|
||||
<p class="si-muted si-small">Nessuna misurazione registrata oggi. Le rilevazioni dei clienti compaiono qui appena arrivano.</p>
|
||||
) : (
|
||||
<p class="si-kpi__value">{misurazioniOggi}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Ultime note">
|
||||
<ul class="si-list">
|
||||
{ultimeNote.map((n) => (
|
||||
<li class="si-note">
|
||||
<p>{n.text}</p>
|
||||
<p class="si-note__meta">{n.clientName ?? 'Nota generale'} · {n.when}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
// Organizzazione: raggruppa più professionisti sotto un'unica struttura. Finché non c'è
|
||||
// il backend, il modulo raccoglie solo il nome e il pulsante resta disabilitato a vuoto.
|
||||
import StressIndex from '../../../layouts/StressIndex.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<StressIndex title="Organizzazione" crumbs={['Organizzazione']}>
|
||||
<div class="si-center">
|
||||
<div class="si-card si-org">
|
||||
<h1>Crea la tua organizzazione</h1>
|
||||
<p class="si-muted si-small">
|
||||
Raggruppa più professionisti sotto un'unica organizzazione per condividere clienti,
|
||||
dati e impostazioni dello studio.
|
||||
</p>
|
||||
<label class="si-field si-mt">
|
||||
Nome organizzazione
|
||||
<input type="text" id="si-org-nome" placeholder="Studio Bianchi Associati" />
|
||||
</label>
|
||||
<p class="si-mt">
|
||||
<button type="button" class="si-btn si-btn--solid si-org__cta" id="si-org-crea" disabled>Crea organizzazione</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
|
||||
<script>
|
||||
// Il pulsante si sblocca solo con un nome valorizzato.
|
||||
const input = document.getElementById('si-org-nome') as HTMLInputElement | null;
|
||||
const btn = document.getElementById('si-org-crea') as HTMLButtonElement | null;
|
||||
if (input && btn) {
|
||||
input.addEventListener('input', () => { btn.disabled = input.value.trim().length === 0; });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
// Modulo Sport. La pagina passa i dati; filtri, schede e aggregazioni stanno nell'isola.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
import SportView from '../../../../components/stress-index/SportView';
|
||||
import { sessions, athletes } from '../../../../lib/stress-index/data';
|
||||
export const prerender = false;
|
||||
|
||||
// Ultimo giorno coperto dai dati dimostrativi.
|
||||
const RIFERIMENTO = '2026-07-12';
|
||||
---
|
||||
<StressIndex title="Sport" crumbs={['Sport']}>
|
||||
<SportView client:load sessions={sessions} athletes={athletes} riferimento={RIFERIMENTO} />
|
||||
</StressIndex>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
// Monitoraggio live: finché non arriva la sorgente dati in tempo reale la vista resta
|
||||
// vuota per costruzione, e spiega cosa succederà quando un atleta avvia una sessione.
|
||||
import StressIndex from '../../../../layouts/StressIndex.astro';
|
||||
export const prerender = false;
|
||||
|
||||
const PASSI = [
|
||||
"L'atleta avvia una sessione dall'app Stress Index collegata alla fascia cardio.",
|
||||
'I dati di frequenza cardiaca e DFA A1 arrivano in tempo reale a questa vista.',
|
||||
"La scheda dell'atleta compare qui da sola, con le metriche aggiornate in continuo.",
|
||||
'A fine sessione la scheda si chiude e il riepilogo confluisce nello storico Sport.',
|
||||
];
|
||||
---
|
||||
<StressIndex title="Team live" crumbs={['Sport', 'Team live']}>
|
||||
<div class="si-head si-head--row">
|
||||
<div>
|
||||
<h1>Team live</h1>
|
||||
<p>Nessun atleta in sessione</p>
|
||||
</div>
|
||||
<label class="si-field si-field--inline">
|
||||
<span class="si-visually-hidden">Ordina per</span>
|
||||
<select>
|
||||
<option value="nome">Ordina per nome</option>
|
||||
<option value="hr">Ordina per HR corrente</option>
|
||||
<option value="carico">Ordina per carico sessione</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="si-card si-live">
|
||||
<p class="si-live__title">Nessun atleta in sessione</p>
|
||||
<p class="si-muted si-small">Quando un atleta avvia una sessione dall'app, la sua scheda compare qui in tempo reale.</p>
|
||||
|
||||
<div class="si-live__how">
|
||||
<p class="si-card__title">Come funziona</p>
|
||||
<ol class="si-steps">
|
||||
{PASSI.map((p, i) => (
|
||||
<li><span class="si-steps__num">{i + 1}</span><span>{p}</span></li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</StressIndex>
|
||||
@@ -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: venerdì 19 settembre 2026 a Bologna. Mini class, personal training, check-up e consulenze gratuite, poi dj set e aperitivo per tutti."
|
||||
>
|
||||
<section class="ev-hero">
|
||||
<div class="ev-hero__inner" data-reveal>
|
||||
<img
|
||||
class="ev-hero__titolo"
|
||||
src="/img/insanity-hour-titolo.png"
|
||||
width="580" height="251"
|
||||
alt="Save the date — In-Sanity Hour"
|
||||
/>
|
||||
<p class="ev-hero__giorno">19</p>
|
||||
<p class="ev-hero__mese">Settembre</p>
|
||||
<p class="ev-hero__inaug">Inaugurazione</p>
|
||||
<p class="ev-hero__claim">Un giorno solo per vivere il Lab da vicino.</p>
|
||||
<p class="ev-hero__gusto">DJ set · Aperitivo</p>
|
||||
<p class="ev-hero__luogo">Via Pietro di Mattiolo, 5 — Bologna</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section ev-intro">
|
||||
<div class="container" data-reveal>
|
||||
<p class="ev-intro__testo">
|
||||
Una giornata aperta a tutti — clienti e non — per scoprire la nuova struttura e conoscere
|
||||
il nostro staff. Vivi l'esperienza In-Sanity Lab da vicino: mini class, allenamenti,
|
||||
consulenze e check up avanzato.
|
||||
</p>
|
||||
<p class="ev-intro__gratis">Solo per oggi, tutto gratuito</p>
|
||||
<p class="ev-intro__testo">
|
||||
L'ingresso e l'aperitivo serale sono liberi, porta chi vuoi. La prenotazione serve solo
|
||||
per assicurarti il posto nei singoli slot di prova.
|
||||
</p>
|
||||
<a class="btn" href={waLink} target="_blank" rel="noopener">Prenotati su WhatsApp</a>
|
||||
<p class="ev-nota">{WHATSAPP} — rispondiamo entro la giornata</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section ev-programma">
|
||||
<div class="container">
|
||||
<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,356 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import T from '../../components/content/T.astro';
|
||||
import { t, resolveContent } from '../../lib/content';
|
||||
import { edizioneAttiva, tagEdizione } from '../../data/promo-edizioni';
|
||||
import { getSessionUser, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { getDb } from '../../lib/db';
|
||||
// Landing promo/lancio integrata nel sito (header e footer del sito, non standalone).
|
||||
// I bottoni "Acquista" portano al gestionale bsport: l'utente crea l'account e sceglie lì
|
||||
// dentro il prodotto, poi paga via Stripe. Il link è uno solo per tutte le card, ed è voluto:
|
||||
// il catalogo lo presenta bsport dopo l'accesso.
|
||||
const BSPORT_URL = 'https://backoffice.bsport.io/login/signup?membership=3428';
|
||||
export const prerender = false;
|
||||
|
||||
// Quale promozione è in vetrina lo decide il calendario, non un deploy: l'edizione attiva è
|
||||
// quella la cui finestra contiene questo istante (vedi `data/promo-edizioni.ts`). Alla
|
||||
// scadenza dell'una subentra l'altra da sola, e quando non ce n'è nessuna la pagina lo dice
|
||||
// invece di lasciare online i prezzi di un'offerta finita.
|
||||
//
|
||||
// Anteprima: `?anteprima=2026-08-20` mostra l'edizione di quella data. Solo per chi è loggato
|
||||
// — un visitatore non deve poter vedere prezzi non ancora validi, o non più.
|
||||
const param = Astro.url.searchParams.get('anteprima');
|
||||
const token = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
const utente = token ? getSessionUser(getDb(), token) : null;
|
||||
// Una data secca (`2026-08-20`) sarebbe letta come UTC: la si porta a mezzogiorno italiano,
|
||||
// lontano dai confini di mezzanotte dove sta la differenza fra un'edizione e l'altra.
|
||||
const quandoParam = param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? `${param}T12:00:00+02:00` : param;
|
||||
const dataLeggibile = Boolean(quandoParam && !Number.isNaN(Date.parse(quandoParam)));
|
||||
const anteprima = Boolean(utente && dataLeggibile);
|
||||
// Un'anteprima chiesta e non applicata va **detta**: altrimenti la pagina risponde con
|
||||
// l'edizione di oggi e due date diverse danno lo stesso risultato — che si legge come «la
|
||||
// funzione non funziona» invece che «non sei entrato».
|
||||
const anteprimaIgnorata = !param || anteprima ? null : (!utente ? 'login' : 'data');
|
||||
const quando = anteprima ? new Date(quandoParam!) : new Date();
|
||||
const ed = edizioneAttiva(quando);
|
||||
// La pagina cambia contenuto col passare del tempo: una copia in cache la congelerebbe alla
|
||||
// vecchia edizione, e lo scambio avverrebbe quando pare al proxy invece che a mezzanotte.
|
||||
Astro.response.headers.set('Cache-Control', 'no-store');
|
||||
|
||||
// Tag dell'edizione attiva: `tg('card.1.price')` → `promo.card.1.price` o `bundle.card.1.price`.
|
||||
const tg = (suffisso: string) => (ed ? tagEdizione(ed, suffisso) : suffisso);
|
||||
// Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}}
|
||||
// che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo.
|
||||
const wa = t('global.whatsapp');
|
||||
const waLink = `https://wa.me/39${wa.replace(/\D/g, '')}`;
|
||||
// Scadenza dell'edizione in corso (tag editabile dal pannello, ISO con offset). Viene solo
|
||||
// passata al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto
|
||||
// col tempo del visitatore.
|
||||
const countdownDeadline = ed ? t(tg('countdown.deadline')) : '';
|
||||
---
|
||||
<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" />
|
||||
<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; }
|
||||
.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; }
|
||||
|
||||
@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; }
|
||||
.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