Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s

Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@iti/shared",
"version": "0.0.0",
"private": true,
"description": "Tipi e DTO condivisi tra apps/api, apps/public e apps/private",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\""
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Configuratore guidato (prompt 18): tipi della definizione del wizard (GET /configurator/definition)
* e del risultato (POST /configurator/evaluate | submit). Nessuna logica: le regole stanno in apps/api.
*/
export const CONFIGURATOR_PATHS = ["acqua", "fotovoltaico", "pompa_calore", "azienda"] as const
export type ConfiguratorPath = (typeof CONFIGURATOR_PATHS)[number]
/** Condizione di visibilità: tutte le condizioni di un elenco devono valere (AND). */
export interface ConfiguratorCondition {
question: string
/** Risposta (scelta singola) tra questi valori. */
in?: string[]
/** Risposta (scelta multipla) che include questo valore. */
includes?: string
}
export interface ConfiguratorOption {
value: string
label: string
help?: string
}
interface QuestionBase {
id: string
label: string
help?: string
/** Obbligatoria quando visibile. */
required: boolean
visibleIf?: ConfiguratorCondition[]
}
export type ConfiguratorQuestion =
| (QuestionBase & { type: "single"; options: ConfiguratorOption[] })
| (QuestionBase & { type: "multi"; options: ConfiguratorOption[]; minSelected: number })
| (QuestionBase & { type: "number"; unit: string; min: number; max: number; step: number })
| (QuestionBase & { type: "province" })
export interface ConfiguratorStep {
id: string
title: string
description?: string
visibleIf?: ConfiguratorCondition[]
questions: ConfiguratorQuestion[]
}
export interface ConfiguratorDefinition {
version: string
steps: ConfiguratorStep[]
/** Province per le domande di tipo "province" (sigla + nome). */
provinces: { code: string; name: string }[]
}
/** Risposte: id domanda → valore (scelta singola e provincia: string; multipla: string[]; numero: number). */
export type ConfiguratorAnswers = Record<string, string | number | string[]>
export interface ConfiguratorResult {
/** Versione delle regole usate (per riprodurre il calcolo). */
rulesVersion: string
path: ConfiguratorPath
solution: { code: string; title: string; description: string }
size: { value: number; unit: string; label: string } | null
/** Prodotti suggeriti (handle Medusa). */
products: { handle: string; quantity: number; note: string | null }[]
/** Stime in centesimi di euro (IVA inclusa); null se non calcolabili per il percorso. */
estimates: {
investmentCents: number | null
annualSavingsCents: number | null
paybackYears: number | null
}
/** Riepilogo leggibile dei calcoli (etichetta/valore). */
details: { label: string; value: string }[]
warnings: string[]
incentivesNote: string
disclaimer: string
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Valori ammessi per gli stati/enumerazioni di dominio.
* Fonte unica: usati da apps/api per gli enum Postgres e dai frontend per select, filtri e badge.
*/
export const INSTALLER_STATUSES = ["pending", "approved", "rejected"] as const
export type InstallerStatus = (typeof INSTALLER_STATUSES)[number]
export const QUOTE_REQUEST_STATUSES = ["new", "contacted", "converted", "closed"] as const
export type QuoteRequestStatus = (typeof QUOTE_REQUEST_STATUSES)[number]
export const QUOTE_STATUSES = ["draft", "sent", "accepted", "rejected", "expired"] as const
export type QuoteStatus = (typeof QUOTE_STATUSES)[number]
export const TICKET_STATUSES = ["open", "in_progress", "waiting_customer", "closed"] as const
export type TicketStatus = (typeof TICKET_STATUSES)[number]
export const TICKET_PRIORITIES = ["low", "normal", "high", "urgent"] as const
export type TicketPriority = (typeof TICKET_PRIORITIES)[number]
export const BLOG_POST_STATUSES = ["draft", "published"] as const
export type BlogPostStatus = (typeof BLOG_POST_STATUSES)[number]
/** Chi può vedere/scaricare un documento: public = anche anonimi. */
export const DOCUMENT_VISIBILITIES = ["public", "customer", "installer", "admin"] as const
export type DocumentVisibility = (typeof DOCUMENT_VISIBILITIES)[number]
export const CONTACT_REQUEST_STATUSES = ["new", "in_progress", "closed"] as const
export type ContactRequestStatus = (typeof CONTACT_REQUEST_STATUSES)[number]
export const SHOWROOM_BOOKING_STATUSES = ["requested", "confirmed", "cancelled", "completed"] as const
export type ShowroomBookingStatus = (typeof SHOWROOM_BOOKING_STATUSES)[number]
/** privacy: obbligatorio per ogni form con dati personali; marketing: facoltativo e revocabile. */
export const CONSENT_TYPES = ["privacy", "marketing"] as const
export type ConsentType = (typeof CONSENT_TYPES)[number]
+445
View File
@@ -0,0 +1,445 @@
/**
* DTO pubblici: forma delle risposte JSON di apps/api consumate dai frontend.
* Date in ISO 8601 (string), importi in centesimi di euro (integer), id uuid.
* Non contengono mai campi interni (hash, note admin, path su disco, payload grezzi).
*/
import type {
BlogPostStatus,
ConsentType,
ContactRequestStatus,
DocumentVisibility,
InstallerStatus,
QuoteRequestStatus,
QuoteStatus,
ShowroomBookingStatus,
TicketPriority,
TicketStatus,
} from "./domain.js"
import type { UserRole } from "./user-role.js"
export type IsoDateTime = string
export interface UserDto {
id: string
email: string
role: UserRole
firstName: string
lastName: string
companyName: string | null
vatNumber: string | null
emailVerified: boolean
createdAt: IsoDateTime
}
/** Utente visto dall'admin (GET /admin/users): stato dell'account e collegamenti. */
export interface UserAdminDto extends UserDto {
disabledAt: IsoDateTime | null
/** True se l'utente ha un customer Medusa collegato (l'id non viene esposto). */
hasShopAccount: boolean
/** Stato del profilo installatore, se esiste. */
installerStatus: InstallerStatus | null
updatedAt: IsoDateTime
}
/** Contatori della dashboard admin (GET /admin/summary). */
export interface AdminSummaryDto {
/** Richieste di preventivo con stato `new`. */
newQuoteRequests: number
/** Ticket `open` (in attesa dell'assistenza). */
openTickets: number
/** Ticket non chiusi (qualunque stato tranne `closed`). */
activeTickets: number
/** Profili installatore `pending`. */
pendingInstallers: number
/** Prenotazioni showroom attive (`requested`/`confirmed`) da oggi in poi. */
upcomingShowroomBookings: number
/** Richieste dal form contatti con stato `new`. */
newContactRequests: number
/** Preventivi inviati in attesa di risposta del cliente. */
sentQuotes: number
}
export interface GeoPoint {
lat: number
lng: number
}
export interface InstallerProfileDto {
userId: string
businessName: string
vatNumber: string | null
phone: string | null
address: string | null
location: GeoPoint | null
serviceRadiusKm: number
certifications: string[]
status: InstallerStatus
approvedAt: IsoDateTime | null
}
/** Profilo installatore visto dall'admin: dati dell'account e note interne. */
export interface InstallerAdminDto extends InstallerProfileDto {
email: string
firstName: string
lastName: string
/** Note interne dell'admin (mai esposte all'installatore). */
notes: string | null
disabled: boolean
createdAt: IsoDateTime
}
/** Installatore che copre un punto, con la distanza dalla sua sede. */
export interface NearbyInstallerDto {
userId: string
businessName: string
email: string
phone: string | null
address: string | null
location: GeoPoint
serviceRadiusKm: number
certifications: string[]
distanceKm: number
}
export interface QuoteRequestDto {
id: string
email: string
name: string
phone: string | null
province: string | null
configuratorInput: unknown
configuratorOutput: unknown
status: QuoteRequestStatus
createdAt: IsoDateTime
}
/** Richiesta di preventivo vista dall'admin (con l'eventuale account collegato). */
export interface QuoteRequestAdminDto extends QuoteRequestDto {
userId: string | null
updatedAt: IsoDateTime
}
export interface QuoteDto {
id: string
quoteRequestId: string | null
number: string
title: string
status: QuoteStatus
amountCents: number
/** True se c'è un PDF allegato: il cliente lo scarica da GET /me/quotes/:id/pdf. */
hasPdf: boolean
/** Data (YYYY-MM-DD) oltre la quale il preventivo scade; null = senza scadenza. */
validUntil: string | null
sentAt: IsoDateTime | null
createdAt: IsoDateTime
}
/** Preventivo visto dall'admin: cliente destinatario e documento PDF. */
export interface QuoteAdminDto extends QuoteDto {
userId: string
customer: { email: string; firstName: string; lastName: string; companyName: string | null }
pdfDocumentId: string | null
}
export interface TicketDto {
id: string
subject: string
status: TicketStatus
priority: TicketPriority
createdAt: IsoDateTime
updatedAt: IsoDateTime
}
export interface TicketDetailDto extends TicketDto {
messages: TicketMessageDto[]
}
/** Ticket visto dall'admin: autore della richiesta. */
export interface TicketAdminDto extends TicketDto {
userId: string
customer: { email: string; firstName: string; lastName: string }
}
export interface TicketMessageDto {
id: string
ticketId: string
authorId: string
/** true se scritto da un admin (risposta dell'assistenza). */
fromStaff: boolean
body: string
createdAt: IsoDateTime
}
export interface BlogCategoryDto {
id: string
slug: string
name: string
description: string | null
}
export interface BlogPostSummaryDto {
id: string
slug: string
title: string
excerpt: string | null
coverUrl: string | null
category: BlogCategoryDto | null
publishedAt: IsoDateTime | null
}
export interface BlogPostDetailDto extends BlogPostSummaryDto {
contentMd: string
/** HTML renderizzato e sanitizzato lato API. */
contentHtml: string
seoTitle: string | null
seoDescription: string | null
updatedAt: IsoDateTime
}
/** Usato dall'admin: include lo stato editoriale. */
export interface BlogPostAdminDto extends BlogPostDetailDto {
status: BlogPostStatus
}
export interface DocumentDto {
id: string
title: string
category: string | null
mimeType: string
sizeBytes: number
visibility: DocumentVisibility
productRef: string | null
createdAt: IsoDateTime
}
export interface ShowroomBookingDto {
id: string
name: string
email: string
phone: string | null
requestedAt: IsoDateTime
people: number
notes: string | null
status: ShowroomBookingStatus
createdAt: IsoDateTime
}
/** Prenotazione showroom vista dall'admin: con l'utente collegato (se prenotata da autenticato). */
export interface ShowroomBookingAdminDto extends ShowroomBookingDto {
userId: string | null
updatedAt: IsoDateTime
}
/** Slot di visita allo showroom (GET /showroom/slots). */
export interface ShowroomSlotDto {
start: IsoDateTime
end: IsoDateTime
/** Posti (persone) ancora prenotabili; 0 = completo. */
available: number
}
export interface ShowroomSlotsDto {
timeZone: string
slotMinutes: number
/** Persone massime per singola prenotazione. */
maxPeoplePerBooking: number
/** Solo i giorni di apertura nel periodo richiesto; date locali YYYY-MM-DD. */
days: { date: string; slots: ShowroomSlotDto[] }[]
}
/** Richiesta dal form contatti (solo admin). */
export interface ContactRequestDto {
id: string
userId: string | null
name: string
email: string
phone: string | null
subject: string | null
message: string
status: ContactRequestStatus
createdAt: IsoDateTime
updatedAt: IsoDateTime
}
/** Token anti-spam dei form pubblici (GET /forms/token). */
export interface FormTokenDto {
token: string
/** Secondi minimi tra emissione del token e invio del form. */
minSeconds: number
expiresAt: IsoDateTime
}
export interface ConsentDto {
id: string
type: ConsentType
version: string
/** Form di provenienza (register, contact, showroom, configurator, account). */
source: string | null
acceptedAt: IsoDateTime
revokedAt: IsoDateTime | null
}
/** Ordine del cliente (da Medusa). Importi in centesimi IVA inclusa. */
export interface MyOrderDto {
/** Numero ordine leggibile (display_id Medusa). */
number: number
status: string
paymentStatus: string
fulfillmentStatus: string
createdAt: IsoDateTime
totals: { items: number; shipping: number; tax: number; total: number }
items: { title: string; sku: string | null; thumbnail: string | null; quantity: number; unitPrice: number; total: number }[]
}
/** Dettaglio di un ordine del cliente: totali con sconto, spedizione e tracking. */
export interface MyOrderDetailDto extends Omit<MyOrderDto, "totals"> {
totals: { items: number; shipping: number; discount: number; tax: number; total: number }
/** Indirizzo di spedizione già composto in righe (nome, azienda, via, CAP città (provincia)); vuoto se assente. */
shippingAddress: string[]
/** Metodi di spedizione scelti (es. "Spedizione standard"). */
shippingMethods: string[]
/** Codici di tracking delle spedizioni, se presenti. */
tracking: { number: string; url: string | null }[]
}
/** Risposta paginata standard. */
export interface Paginated<T> {
items: T[]
total: number
limit: number
offset: number
}
// --- Shop (proxy Medusa, prompt 13) ---
// Importi in centesimi di euro, IVA inclusa. Prodotti e categorie identificati dall'handle;
// gli unici id Medusa esposti sono quelli che il frontend deve rimandare (variante, riga, opzione di spedizione).
/** "installer": prezzo dal listino riservato installatori; "standard": prezzo di listino pubblico. */
export type ShopPriceType = "standard" | "installer"
export interface ShopCategoryDto {
handle: string
name: string
description: string | null
parentHandle: string | null
}
export interface ShopProductSummaryDto {
handle: string
title: string
subtitle: string | null
thumbnail: string | null
/** Prezzo più basso tra le varianti (null se nessuna variante ha un prezzo). */
fromPrice: number | null
/**
* Prezzo di listino pubblico della stessa variante di `fromPrice`: con `priceType: "installer"` è il prezzo
* da confrontare con quello riservato (listino installatori). Null se non c'è prezzo.
*/
fromOriginalPrice: number | null
priceType: ShopPriceType
categories: { handle: string; name: string }[]
}
export interface ShopVariantDto {
/** Da inviare a POST /shop/cart/items. */
id: string
title: string
sku: string | null
price: number | null
/** Prezzo di listino pubblico (diverso da price se si applica un listino riservato). */
originalPrice: number | null
inStock: boolean
options: { name: string; value: string }[]
}
export interface ShopProductDetailDto extends ShopProductSummaryDto {
description: string | null
images: string[]
variants: ShopVariantDto[]
}
export interface ShopAddressDto {
firstName: string
lastName: string
company: string | null
address1: string
address2: string | null
city: string
postalCode: string
province: string | null
countryCode: string
phone: string | null
}
/**
* Dati per la fattura a un'azienda, chiesti al checkout. Salvati nei metadata del carrello Medusa
* (`metadata.invoice`), che passano all'ordine.
*/
export interface ShopInvoiceDto {
companyName: string
/** Partita IVA italiana (11 cifre). */
vatNumber: string
/** Codice fiscale dell'azienda, se diverso dalla partita IVA. */
fiscalCode: string | null
/** Codice destinatario SDI (7 caratteri). */
sdiCode: string | null
pec: string | null
}
export interface ShopCartItemDto {
/** Id della riga: per PATCH/DELETE /shop/cart/items/:id. */
id: string
variantId: string
productHandle: string | null
productTitle: string
variantTitle: string | null
sku: string | null
thumbnail: string | null
quantity: number
unitPrice: number
total: number
}
export interface ShopCartDto {
email: string | null
items: ShopCartItemDto[]
itemCount: number
shippingAddress: ShopAddressDto | null
billingAddress: ShopAddressDto | null
/** Dati per la fattura aziendale (null = nessuna fattura richiesta). */
invoice: ShopInvoiceDto | null
shippingMethod: { optionId: string; name: string; amount: number } | null
totals: {
items: number
shipping: number
discount: number
/** IVA compresa nel totale (scorporata). */
tax: number
total: number
}
/** True se il carrello ha email, indirizzo, spedizione e almeno una riga. */
readyForPayment: boolean
}
export interface ShopShippingOptionDto {
/** Da inviare a POST /shop/cart/shipping. */
id: string
name: string
amount: number
}
export interface ShopPaymentDto {
provider: "stripe"
/** client_secret del PaymentIntent Stripe, per confirmPayment con Stripe.js. */
clientSecret: string
amount: number
}
export interface ShopOrderDto {
/** Numero ordine leggibile (display_id Medusa). */
number: number
email: string
createdAt: IsoDateTime
items: { productTitle: string; variantTitle: string | null; quantity: number; unitPrice: number; total: number }[]
totals: { items: number; shipping: number; tax: number; total: number }
}
+6
View File
@@ -0,0 +1,6 @@
export type { UserRole } from "./user-role.js"
export { USER_ROLES } from "./user-role.js"
export * from "./domain.js"
export type * from "./dto.js"
export * from "./configurator.js"
export * from "./validation.js"
+4
View File
@@ -0,0 +1,4 @@
/** Ruoli applicativi di un utente (colonna `users.role`, enum Postgres `user_role`). */
export const USER_ROLES = ["customer", "company", "installer", "admin"] as const
export type UserRole = (typeof USER_ROLES)[number]
+34
View File
@@ -0,0 +1,34 @@
// Validazioni condivise tra API e frontend (funzioni pure, nessuna dipendenza).
/** Normalizza una partita IVA italiana: senza spazi e senza prefisso "IT". */
export function normalizeVatNumber(value: string): string {
return value.replace(/\s+/g, "").toUpperCase().replace(/^IT/, "")
}
/** Partita IVA italiana valida: 11 cifre con cifra di controllo corretta (algoritmo di Luhn). */
export function isValidItalianVat(value: string): boolean {
const vat = normalizeVatNumber(value)
if (!/^\d{11}$/.test(vat)) return false
let sum = 0
for (let i = 0; i < 10; i++) {
let d = Number(vat[i])
if (i % 2 === 1) {
d *= 2
if (d > 9) d -= 9
}
sum += d
}
return (10 - (sum % 10)) % 10 === Number(vat[10])
}
/** Codice fiscale: 16 caratteri alfanumerici (persona) o 11 cifre (azienda). Controllo solo di formato. */
export const FISCAL_CODE_PATTERN = /^([A-Z0-9]{16}|\d{11})$/
/** Codice destinatario SDI: 7 caratteri alfanumerici (es. "0000000" per chi non ne ha uno). */
export const SDI_CODE_PATTERN = /^[A-Z0-9]{7}$/
/** Riquadro che contiene l'Italia (isole minori comprese): le coordinate fuori sono un errore di input. */
export const ITALY_BOUNDS = { minLat: 35.4, maxLat: 47.1, minLng: 6.6, maxLng: 18.6 } as const
export const isInItaly = (p: { lat: number; lng: number }): boolean =>
p.lat >= ITALY_BOUNDS.minLat && p.lat <= ITALY_BOUNDS.maxLat && p.lng >= ITALY_BOUNDS.minLng && p.lng <= ITALY_BOUNDS.maxLng
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+300
View File
@@ -0,0 +1,300 @@
// Base globale condivisa da apps/public e apps/private: custom properties dai token, reset essenziale,
// tipografia, utility di layout, pulsanti e form. Ogni app la include dal proprio styles.scss con `@use 'base';`.
@use 'sass:map';
@use 'tokens';
@use 'mixins' as mq;
:root {
@each $name, $value in tokens.$colors {
--color-#{$name}: #{$value};
}
@each $name, $value in tokens.$font-sizes {
--font-size-#{$name}: #{$value};
}
@each $name, $value in tokens.$line-heights {
--line-height-#{$name}: #{$value};
}
@each $name, $value in tokens.$font-weights {
--font-weight-#{$name}: #{$value};
}
@each $name, $value in tokens.$spaces {
--space-#{$name}: #{$value};
}
@each $name, $value in tokens.$radii {
--radius-#{$name}: #{$value};
}
@each $name, $value in tokens.$shadows {
--shadow-#{$name}: #{$value};
}
--font-sans: #{tokens.$font-sans};
--font-mono: #{tokens.$font-mono};
--container-max: #{tokens.$container-max};
--header-height: #{tokens.$header-height};
--gutter: var(--space-4);
color-scheme: light;
@include mq.up(md) {
--gutter: var(--space-5);
}
}
// --- Reset essenziale ---
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
scroll-padding-top: calc(var(--header-height) + var(--space-4));
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-sans);
font-size: var(--font-size-md);
line-height: var(--line-height-normal);
color: var(--color-ink);
background: var(--color-surface);
-webkit-font-smoothing: antialiased;
}
img,
svg,
video {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
}
// --- Tipografia ---
h1,
h2,
h3,
h4 {
margin: 0 0 var(--space-4);
line-height: var(--line-height-tight);
font-weight: var(--font-weight-bold);
color: var(--color-ink);
text-wrap: balance;
}
h1 {
font-size: var(--font-size-4xl);
}
h2 {
font-size: var(--font-size-3xl);
}
h3 {
font-size: var(--font-size-2xl);
}
h4 {
font-size: var(--font-size-xl);
}
p {
margin: 0 0 var(--space-4);
}
a {
color: var(--color-primary);
text-underline-offset: 0.15em;
&:hover {
color: var(--color-primary-strong);
}
@include mq.focus-ring;
}
button {
@include mq.focus-ring;
}
// --- Utility ---
.container {
width: 100%;
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--gutter);
}
.visually-hidden {
@include mq.visually-hidden;
}
.lead {
font-size: var(--font-size-lg);
color: var(--color-ink-muted);
max-width: 65ch;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
padding: var(--space-2) var(--space-5);
border: 2px solid transparent;
border-radius: var(--radius-pill);
font-weight: var(--font-weight-semibold);
text-decoration: none;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
&--primary {
background: var(--color-primary);
color: var(--color-on-primary);
&:hover {
background: var(--color-primary-strong);
color: var(--color-on-primary);
}
}
&--accent {
background: var(--color-accent);
color: var(--color-on-accent);
&:hover {
background: #{map.get(tokens.$colors, 'accent')};
color: var(--color-on-accent);
filter: brightness(0.95);
}
}
&--outline {
border-color: currentColor;
background: transparent;
color: var(--color-primary);
}
}
// --- Form ---
// Campi: <app-form-field> (shared/form-field.ts) ha già la classe .form-field.
.form-grid {
display: grid;
gap: var(--space-4);
@include mq.up(md) {
grid-template-columns: 1fr 1fr;
> .span-2 {
grid-column: 1 / -1;
}
}
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
label {
font-weight: var(--font-weight-semibold);
}
.optional {
font-weight: var(--font-weight-regular);
color: var(--color-ink-muted);
}
.hint {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
input,
select,
textarea {
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-ink-muted);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-ink);
font: inherit;
&[aria-invalid='true'] {
border-color: var(--color-danger);
box-shadow: 0 0 0 1px var(--color-danger);
}
}
.error {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-danger);
}
}
.field-error {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-danger);
}
fieldset {
// min-width: 0 → il contenuto largo (es. fila di giorni scorrevole) non allarga il fieldset (default min-content)
min-width: 0;
margin: 0 0 var(--space-5);
padding: 0;
border: 0;
legend {
margin-bottom: var(--space-3);
padding: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
}
}
// Casella o radio con etichetta, area cliccabile ≥ 44 px
.check {
display: flex;
align-items: flex-start;
gap: var(--space-3);
min-height: 44px;
padding-block: var(--space-2);
cursor: pointer;
input {
flex: none;
width: 1.25rem;
height: 1.25rem;
margin: 0.15rem 0 0;
accent-color: var(--color-primary);
}
}
.alert {
margin: 0 0 var(--space-4);
padding: var(--space-3) var(--space-4);
border-left: 4px solid currentColor;
border-radius: var(--radius-md);
background: var(--color-surface-alt);
&--error {
color: var(--color-danger);
}
&--success {
color: var(--color-success);
}
}
+35
View File
@@ -0,0 +1,35 @@
@use 'sass:map';
@use 'tokens';
/// Media query mobile-first: stili applicati da quel breakpoint in su.
/// @example @include mq.up(md) { … }
@mixin up($name) {
$value: map.get(tokens.$breakpoints, $name);
@if not $value {
@error "Breakpoint sconosciuto: #{$name}";
}
@media (min-width: $value) {
@content;
}
}
/// Testo leggibile solo dagli screen reader.
@mixin visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/// Anello di focus visibile e coerente (solo da tastiera).
@mixin focus-ring {
&:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
}
+104
View File
@@ -0,0 +1,104 @@
// Tipografia del corpo degli articoli del blog: HTML generato dal markdown dall'API (marked + sanitize-html).
// Usata dal sito pubblico (pagina articolo) e dall'anteprima dell'editor admin: stesso aspetto nei due punti.
// Il contenuto è inserito con [innerHTML], senza attributi di incapsulamento: includere dentro `::ng-deep`.
/// Stili del contenitore (larghezza di lettura, corpo del testo).
@mixin container {
max-width: 72ch;
font-size: var(--font-size-lg);
line-height: var(--line-height-normal);
}
/// Stili degli elementi generati dal markdown.
/// @example .prose { @include prose.container; } .prose ::ng-deep { @include prose.content; }
@mixin content {
h2,
h3,
h4 {
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
h2 {
font-size: var(--font-size-2xl);
}
h3 {
font-size: var(--font-size-xl);
}
p,
ul,
ol,
blockquote,
pre,
table,
figure {
margin-block: 0 var(--space-4);
}
li + li {
margin-top: var(--space-2);
}
img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
}
figure {
margin-inline: 0;
}
figcaption {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-ink-muted);
}
blockquote {
margin-inline: 0;
padding: var(--space-3) var(--space-5);
border-left: 4px solid var(--color-primary);
background: var(--color-primary-soft);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
p:last-child {
margin-bottom: 0;
}
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
padding: 0.1em 0.3em;
border-radius: var(--radius-sm);
background: var(--color-surface-alt);
}
pre {
overflow-x: auto;
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
font-size: var(--font-size-sm);
code {
padding: 0;
background: none;
}
}
table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: var(--font-size-md);
}
th,
td {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-line);
text-align: left;
}
th {
background: var(--color-surface-alt);
}
hr {
margin-block: var(--space-6);
border: 0;
border-top: 1px solid var(--color-line);
}
}
+105
View File
@@ -0,0 +1,105 @@
// Design tokens condivisi da apps/public e apps/private.
// I valori vivono come custom properties CSS (:root, generate in _base.scss): i componenti usano var(--…).
// Le mappe SCSS servono per generarle e per i breakpoint (le media query non accettano var()).
// --- Colori ---
// Palette: blu acqua (brand, coerente con le email: #0b5c7a), verde sostenibilità, ambra energia solare.
$colors: (
// Brand
'primary': #0b5c7a,
'primary-strong': #08445b,
'primary-soft': #e6f2f6,
'secondary': #1f7a4d,
'secondary-soft': #e8f5ee,
'accent': #f2a900,
'accent-soft': #fff6dc,
// Macro-aree (badge, icone, bordi)
'area-acqua': #0e7fa6,
'area-energia': #c98a00,
'area-comfort': #c2410c,
'area-mobilita': #1f7a4d,
'area-industria': #475569,
// Neutri
'ink': #12212b,
'ink-muted': #4a5a66,
'line': #d9e1e6,
'surface': #ffffff,
'surface-alt': #f4f7f9,
'on-primary': #ffffff,
'on-accent': #12212b,
// Stati
'success': #1f7a4d,
'warning': #9a6700,
'danger': #b42318,
'focus': #f2a900,
);
// --- Tipografia ---
// Font di sistema: nessuna richiesta a servizi esterni (GDPR, prestazioni). Si potrà adottare un font self-hosted.
$font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
$font-mono: ui-monospace, 'Cascadia Code', 'SFMono-Regular', Menlo, Consolas, monospace;
// Scala tipografica fluida (mobile → desktop)
$font-sizes: (
'xs': 0.8125rem,
'sm': 0.875rem,
'md': 1rem,
'lg': 1.125rem,
'xl': clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem),
'2xl': clamp(1.5rem, 1.3rem + 1vw, 2rem),
'3xl': clamp(1.875rem, 1.5rem + 1.8vw, 2.75rem),
'4xl': clamp(2.25rem, 1.7rem + 2.6vw, 3.5rem),
);
$line-heights: (
'tight': 1.2,
'snug': 1.35,
'normal': 1.6,
);
$font-weights: (
'regular': 400,
'medium': 500,
'semibold': 600,
'bold': 700,
);
// --- Spaziature (scala 4px) ---
$spaces: (
'0': 0,
'1': 0.25rem,
'2': 0.5rem,
'3': 0.75rem,
'4': 1rem,
'5': 1.5rem,
'6': 2rem,
'7': 3rem,
'8': 4rem,
'9': 6rem,
);
// --- Forme ed effetti ---
$radii: (
'sm': 4px,
'md': 8px,
'lg': 16px,
'pill': 999px,
);
$shadows: (
'sm': 0 1px 2px rgb(18 33 43 / 8%),
'md': 0 4px 12px rgb(18 33 43 / 10%),
'lg': 0 12px 32px rgb(18 33 43 / 14%),
);
// --- Layout ---
$container-max: 1200px;
$header-height: 64px;
// Breakpoint mobile-first (min-width)
$breakpoints: (
'sm': 576px,
'md': 768px,
'lg': 1024px,
'xl': 1280px,
);