Merge remote-tracking branch 'origin/feat/split-apps' into merge-split

# Conflicts:
#	apps/sito/compose.yaml
This commit is contained in:
Adriano Dal Pastro
2026-09-04 10:09:29 +00:00
583 changed files with 56050 additions and 257 deletions
+122
View File
@@ -0,0 +1,122 @@
// Edizioni della pagina "Promo ed eventi".
//
// Una promozione ha una fine, e la pagina che la mostra deve saperla: qui sta scritto quale
// set di contenuti è in vetrina e in quale finestra di tempo. La pagina non decide niente,
// legge l'edizione attiva nell'istante della richiesta — così il passaggio da una promozione
// alla successiva avviene da solo, alla mezzanotte giusta, senza deploy e senza che qualcuno
// debba ricordarsene.
//
// I testi restano nei tag (`<prefix>.*`, modificabili senza deploy). Qui c'è solo la
// struttura: quante card, quali campi ha ciascuna, quali FAQ. Aggiungere un'edizione =
// una voce in questo elenco + i suoi tag nel seed; i test in `tests/promo-edizioni.test.ts`
// verificano che non manchi niente e che le finestre non si sovrappongano né lascino buchi.
export interface CardSpec {
/** Numero della card: forma i tag `<prefix>.card.<id>.*`. */
id: number;
/** Quante voci ha l'elenco puntato (`feature.1` … `feature.N`). */
features: number;
/** Card in evidenza: cornice diversa e badge `card.<id>.badge`. */
featured?: boolean;
/** Riga sotto il prezzo, es. "5 incontri". */
unit?: boolean;
/** Prezzo pieno barrato accanto a quello promo. Senza, la card mostra il solo prezzo di
* vendita: un listino barrato non dichiarato dal cliente sarebbe un numero inventato. */
priceOld?: boolean;
/** Domanda in testa alla card, es. "Ti alleni ma non vedi risultati?". */
question?: boolean;
/** Paragrafo descrittivo sotto l'elenco. */
desc?: boolean;
/** Riquadro in fondo col periodo di acquisto e di utilizzo. */
note?: boolean;
}
export interface Edizione {
id: string;
/** Prefisso dei tag di contenuto di questa edizione. */
prefix: string;
/** Inizio e fine validità, ISO **con offset esplicito**: il server gira in UTC e senza
* offset lo scambio avverrebbe due ore prima di quanto scritto sul sito. */
from: string;
to: string;
cards: CardSpec[];
/** Numeri delle FAQ, nell'ordine di comparsa. */
faq: number[];
/** Sezioni "launch" e "why5" fra l'hero e le card. */
launch?: boolean;
}
export const edizioni: Edizione[] = [
{
// Promo di lancio: 4 pacchetti da 5 incontri, online dal 1° agosto.
id: 'agosto-2026',
prefix: 'promo',
from: '2026-08-01T00:00:00+02:00',
to: '2026-08-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true, priceOld: true },
{ id: 2, features: 4, unit: true, priceOld: true, featured: true },
{ id: 3, features: 3, unit: true, priceOld: true },
{ id: 4, features: 3, unit: true, priceOld: true },
],
faq: [0, 1, 2, 3, 4, 5, 6],
},
{
// Opening Bundles: subentra alla promo agosto il giorno dopo la sua scadenza, si
// acquistano fino al 4 settembre e si usano dal 1° settembre al 1° dicembre.
id: 'bundle-apertura',
prefix: 'bundle',
from: '2026-08-20T00:00:00+02:00',
to: '2026-09-04T23:59:59+02:00',
cards: [
{ id: 1, features: 3, question: true, desc: true, note: true, priceOld: true },
{ id: 2, features: 2, question: true, desc: true, note: true, priceOld: true },
// Nessuna card "in evidenza": sono tre percorsi per esigenze diverse, non tre livelli
// dello stesso prodotto, e un "più scelto" su un'offerta appena nata sarebbe inventato.
{ id: 3, features: 3, question: true, desc: true, note: true, priceOld: true },
],
faq: [0, 1, 2, 3, 4],
},
{
// Promo di settembre: subentra ai bundle il 5 e chiude il 19, il giorno dell'inaugurazione.
// Torna la forma dei pacchetti da 5 incontri, con in più il Check-up avanzato, che è una
// valutazione singola e non un percorso — per questo ha "1 valutazione" al posto di
// "5 incontri".
// Nessun prezzo barrato: le creative del cliente danno il solo prezzo promo, e un listino
// pieno ripreso dall'edizione di agosto sarebbe un numero che nessuno ha confermato.
id: 'settembre-2026',
prefix: 'settembre',
from: '2026-09-05T00:00:00+02:00',
to: '2026-09-19T23:59:59+02:00',
launch: true,
cards: [
{ id: 1, features: 4, unit: true },
{ id: 2, features: 4, unit: true },
{ id: 3, features: 3, unit: true },
{ id: 4, features: 3, unit: true },
// Nessuna card "in evidenza": cinque proposte per esigenze diverse, non cinque livelli
// dello stesso prodotto.
{ id: 5, features: 3, unit: true },
],
// La 7 è la domanda nuova sul Check-up: va letta dopo le altre due "come funziona", non
// in fondo dopo il rimborso, quindi l'ordine di comparsa non segue la numerazione.
faq: [0, 1, 2, 7, 3, 4, 5, 6],
},
];
/** Tag di contenuto di un'edizione: `tagEdizione(e, 'card.1.price')` → `bundle.card.1.price`. */
export const tagEdizione = (e: Edizione, suffisso: string): string => `${e.prefix}.${suffisso}`;
/**
* L'edizione valida in un istante, o `null` se non ce n'è nessuna (e allora la pagina mostra
* lo stato "nessuna promozione in corso" invece di prezzi scaduti).
*
* Il confronto è fra istanti assoluti: `Date.parse` interpreta l'offset scritto nel dato, e
* `now` arriva dall'orologio del server. Nessuna delle due parti dipende dal fuso della
* macchina, che in produzione è UTC e in sviluppo no.
*/
export function edizioneAttiva(now: Date, list: Edizione[] = edizioni): Edizione | null {
const t = now.getTime();
return list.find((e) => t >= Date.parse(e.from) && t <= Date.parse(e.to)) ?? null;
}