Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
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:
@@ -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})\""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user