Il repo resta al sito: app in radice, piattaforme nel loro repo
apps/sito passa in radice, apps/platforms si toglie: vive in gitea:Adriano/InsanityLab-platforms, con la storia del repo comune. I piani e le specifiche di Longevity e Campus lasciano docs/ e seguono le piattaforme. README e compose: deploy da /opt/docker/insanitylab/src; al primo deploy .env va spostato da apps/sito/.env alla radice. Refs #20 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
---
|
||||
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" />
|
||||
{slug === 'reformer-class' && (
|
||||
<p class="prog__cta"><a class="btn" href="/contact">{t('service.contact-cta')}</a></p>
|
||||
)}
|
||||
{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__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); }
|
||||
/* Il calendario dei corsi (popup) è staccato dall'elenco degli altri servizi e pieno
|
||||
nel colore d'accento: il cliente lo vuole evidente (InsanityLab-web#19). */
|
||||
.prog__calendario { display: flex; align-items: center; gap: 14px; width: 100%; margin-top: 36px; padding: 10px 16px 10px 10px; border: 0; cursor: pointer; text-align: left; background: var(--c-dark); color: #fff; font-family: var(--font-heading); font-size: .82rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; transition: background .2s; }
|
||||
.prog__calendario:hover { background: var(--c-accent-dark); }
|
||||
.prog__cta { margin: 10px 0 30px; }
|
||||
.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>
|
||||
Reference in New Issue
Block a user